Spaces:
Paused
Paused
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
|
| 2 |
-
|
| 3 |
from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash
|
| 4 |
import json
|
| 5 |
import os
|
|
@@ -10,48 +9,34 @@ from datetime import datetime
|
|
| 10 |
from huggingface_hub import HfApi, hf_hub_download
|
| 11 |
from huggingface_hub.utils import RepositoryNotFoundError
|
| 12 |
from werkzeug.utils import secure_filename
|
| 13 |
-
# Импортируем dotenv для загрузки переменных окружения из .env файла
|
| 14 |
from dotenv import load_dotenv
|
| 15 |
|
| 16 |
-
# Загружаем переменные окружения из файла .env (если он есть)
|
| 17 |
load_dotenv()
|
| 18 |
|
| 19 |
app = Flask(__name__)
|
| 20 |
-
app.secret_key = 'your_unique_secret_key_soola_cosmetics_67890'
|
| 21 |
DATA_FILE = 'data_soola.json'
|
| 22 |
USERS_FILE = 'users_soola.json'
|
| 23 |
|
| 24 |
-
# Список файлов для синхронизации
|
| 25 |
SYNC_FILES = [DATA_FILE, USERS_FILE]
|
| 26 |
|
| 27 |
-
|
| 28 |
-
# Убедитесь, что REPO_ID соответствует вашему репозиторию на Hugging Face
|
| 29 |
-
REPO_ID = "Kgshop/Soola" # Замените на ваш ID, если он другой
|
| 30 |
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
|
| 31 |
-
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
| 32 |
|
| 33 |
-
|
| 34 |
-
STORE_ADDRESS = "Рынок Дордой, Джунхай, терминал, 38" # Единый адрес
|
| 35 |
|
| 36 |
-
# Валюта (только KGS)
|
| 37 |
CURRENCY_CODE = 'KGS'
|
| 38 |
CURRENCY_NAME = 'Кыргызский сом (с)'
|
| 39 |
|
| 40 |
-
# Настройка логирования
|
| 41 |
-
# Уровни: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
| 42 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 43 |
|
| 44 |
-
# --- Функции работы с данными и пользователями ---
|
| 45 |
-
|
| 46 |
def load_data():
|
| 47 |
-
"""Загрузка данных о товарах и категориях."""
|
| 48 |
try:
|
| 49 |
-
# Попытка скачать актуальные данные перед чтением локальных
|
| 50 |
download_db_from_hf()
|
| 51 |
with open(DATA_FILE, 'r', encoding='utf-8') as file:
|
| 52 |
data = json.load(file)
|
| 53 |
logging.info(f"Данные успешно загружены из {DATA_FILE}")
|
| 54 |
-
# Проверка базовой структуры
|
| 55 |
if not isinstance(data, dict):
|
| 56 |
logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.")
|
| 57 |
return {'products': [], 'categories': []}
|
|
@@ -63,13 +48,11 @@ def load_data():
|
|
| 63 |
except FileNotFoundError:
|
| 64 |
logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачать с HF.")
|
| 65 |
try:
|
| 66 |
-
# download_db_from_hf() # Уже вызывали выше, избегаем повторного вызова при первой ошибке
|
| 67 |
-
# Если скачивание не удалось выше, пытаемся просто создать пустые файлы
|
| 68 |
if not os.path.exists(DATA_FILE):
|
| 69 |
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f)
|
| 70 |
logging.info(f"Создан пустой файл {DATA_FILE}")
|
| 71 |
return {'products': [], 'categories': []}
|
| 72 |
-
else:
|
| 73 |
with open(DATA_FILE, 'r', encoding='utf-8') as file:
|
| 74 |
data = json.load(file)
|
| 75 |
logging.info(f"Данные успешно загружены из {DATA_FILE} после попытки скачивания.")
|
|
@@ -77,7 +60,7 @@ def load_data():
|
|
| 77 |
if 'products' not in data: data['products'] = []
|
| 78 |
if 'categories' not in data: data['categories'] = []
|
| 79 |
return data
|
| 80 |
-
except (FileNotFoundError, RepositoryNotFoundError) as e:
|
| 81 |
logging.warning(f"Файл {DATA_FILE} не найден локально и ошибка при доступе к репозиторию HF ({e}). Создание пустой структуры.")
|
| 82 |
if not os.path.exists(DATA_FILE):
|
| 83 |
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f)
|
|
@@ -90,7 +73,6 @@ def load_data():
|
|
| 90 |
return {'products': [], 'categories': []}
|
| 91 |
except json.JSONDecodeError:
|
| 92 |
logging.error(f"Ошибка декодирования JSON в локальном {DATA_FILE}. Файл может быть поврежден. Возврат пустой структуры.")
|
| 93 |
-
# Можно добавить логику восстановления из бэкапа или HF, если нужно
|
| 94 |
return {'products': [], 'categories': []}
|
| 95 |
except Exception as e:
|
| 96 |
logging.error(f"Неизвестная ошибка при загрузке данных ({DATA_FILE}): {e}", exc_info=True)
|
|
@@ -98,23 +80,16 @@ def load_data():
|
|
| 98 |
|
| 99 |
|
| 100 |
def save_data(data):
|
| 101 |
-
"""Сохранение данных о товарах и категориях."""
|
| 102 |
try:
|
| 103 |
with open(DATA_FILE, 'w', encoding='utf-8') as file:
|
| 104 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 105 |
logging.info(f"Данные успешно сохранены в {DATA_FILE}")
|
| 106 |
-
# Загрузка на HF после сохранения (можно сделать опциональной)
|
| 107 |
upload_db_to_hf(specific_file=DATA_FILE)
|
| 108 |
except Exception as e:
|
| 109 |
logging.error(f"Ошибка при сохранении данных в {DATA_FILE}: {e}", exc_info=True)
|
| 110 |
-
# В реальном приложении можно добавить механизм повторной попытки или уведомления
|
| 111 |
-
# raise # Перевыброс исключения может остановить приложение, если не обработан выше
|
| 112 |
|
| 113 |
def load_users():
|
| 114 |
-
"""Загрузка данных пользователей."""
|
| 115 |
try:
|
| 116 |
-
# Опционально: скачать файл пользователей перед чтением
|
| 117 |
-
# download_db_from_hf(specific_file=USERS_FILE) # Раскомментировать, если нужно всегда свежие пользователи
|
| 118 |
with open(USERS_FILE, 'r', encoding='utf-8') as file:
|
| 119 |
users = json.load(file)
|
| 120 |
logging.info(f"Данные пользователей успешно загружены из {USERS_FILE}")
|
|
@@ -122,15 +97,13 @@ def load_users():
|
|
| 122 |
except FileNotFoundError:
|
| 123 |
logging.warning(f"Локальный файл {USERS_FILE} не найден. Попытка скачать с HF.")
|
| 124 |
try:
|
| 125 |
-
download_db_from_hf(specific_file=USERS_FILE)
|
| 126 |
-
# Повторная попытка чтения после скачивания
|
| 127 |
with open(USERS_FILE, 'r', encoding='utf-8') as file:
|
| 128 |
users = json.load(file)
|
| 129 |
logging.info(f"Данные пользователей загружены из {USERS_FILE} после скачивания.")
|
| 130 |
return users if isinstance(users, dict) else {}
|
| 131 |
except (FileNotFoundError, RepositoryNotFoundError):
|
| 132 |
logging.warning(f"Файл {USERS_FILE} не найден локально и в репозитории HF. Создание пустого файла.")
|
| 133 |
-
# Создаем пустой файл, если его нет
|
| 134 |
with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f)
|
| 135 |
return {}
|
| 136 |
except json.JSONDecodeError:
|
|
@@ -147,22 +120,16 @@ def load_users():
|
|
| 147 |
return {}
|
| 148 |
|
| 149 |
def save_users(users):
|
| 150 |
-
"""Сохранение данных пользователей."""
|
| 151 |
try:
|
| 152 |
with open(USERS_FILE, 'w', encoding='utf-8') as file:
|
| 153 |
json.dump(users, file, ensure_ascii=False, indent=4)
|
| 154 |
logging.info(f"Данные пользователей успешно сохранены в {USERS_FILE}")
|
| 155 |
-
# Загрузка на HF после сохранения
|
| 156 |
upload_db_to_hf(specific_file=USERS_FILE)
|
| 157 |
except Exception as e:
|
| 158 |
logging.error(f"Ошибка при сохранении данных пользователей в {USERS_FILE}: {e}", exc_info=True)
|
| 159 |
|
| 160 |
-
# --- Функции синхронизации с Hugging Face ---
|
| 161 |
|
| 162 |
def upload_db_to_hf(specific_file=None):
|
| 163 |
-
"""Загрузка файлов данных на Hugging Face.
|
| 164 |
-
Если specific_file указан, загружает только его.
|
| 165 |
-
"""
|
| 166 |
if not HF_TOKEN_WRITE:
|
| 167 |
logging.warning("Переменная окружения HF_TOKEN (для записи) не установлена. Загрузка на Hugging Face пропущена.")
|
| 168 |
return
|
|
@@ -185,7 +152,6 @@ def upload_db_to_hf(specific_file=None):
|
|
| 185 |
logging.info(f"Файл {file_name} успешно загружен на Hugging Face.")
|
| 186 |
except Exception as e:
|
| 187 |
logging.error(f"Ошибка при загрузке файла {file_name} на Hugging Face: {e}")
|
| 188 |
-
# Продолжаем пытаться загрузить другие файлы
|
| 189 |
else:
|
| 190 |
logging.warning(f"Файл {file_name} не найден локально, пропуск загрузки.")
|
| 191 |
logging.info("Загрузка файлов на HF завершена.")
|
|
@@ -193,79 +159,58 @@ def upload_db_to_hf(specific_file=None):
|
|
| 193 |
logging.error(f"Общая ошибка при инициализации или загрузке на Hugging Face: {e}", exc_info=True)
|
| 194 |
|
| 195 |
def download_db_from_hf(specific_file=None):
|
| 196 |
-
"""Скачивание файлов данных с Hugging Face.
|
| 197 |
-
Если specific_file указан, скачивает только его.
|
| 198 |
-
"""
|
| 199 |
if not HF_TOKEN_READ:
|
| 200 |
-
# Можно использовать и без токена для публичных репозиториев, но лучше предупредить
|
| 201 |
logging.warning("Переменная окружения HF_TOKEN_READ не установлена. Попытка скачивания с Hugging Face без токена (может не сработать для приватных репо).")
|
| 202 |
-
# Не выходим, пытаемся скачать анонимно
|
| 203 |
|
| 204 |
files_to_download = [specific_file] if specific_file else SYNC_FILES
|
| 205 |
logging.info(f"Начало скачивания файлов {files_to_download} с HF репозитория {REPO_ID}...")
|
| 206 |
downloaded_files_count = 0
|
| 207 |
try:
|
| 208 |
-
# HfApi() не нужен для hf_hub_download, но можно использовать для проверки существования репо
|
| 209 |
-
# api = HfApi()
|
| 210 |
-
# api.dataset_info(repo_id=REPO_ID, token=HF_TOKEN_READ) # Проверка доступности репо
|
| 211 |
-
|
| 212 |
for file_name in files_to_download:
|
| 213 |
try:
|
| 214 |
-
# Скачиваем в текущую директорию, перезаписывая существующие файлы
|
| 215 |
local_path = hf_hub_download(
|
| 216 |
repo_id=REPO_ID,
|
| 217 |
filename=file_name,
|
| 218 |
repo_type="dataset",
|
| 219 |
-
token=HF_TOKEN_READ,
|
| 220 |
local_dir=".",
|
| 221 |
local_dir_use_symlinks=False,
|
| 222 |
-
force_download=True
|
| 223 |
)
|
| 224 |
logging.info(f"Файл {file_name} успешно скачан из Hugging Face в {local_path}.")
|
| 225 |
downloaded_files_count += 1
|
| 226 |
except RepositoryNotFoundError:
|
| 227 |
logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.")
|
| 228 |
-
break
|
| 229 |
-
except Exception as e:
|
| 230 |
-
# Проверяем, является ли ошибка 'Not Found' для конкретного файла
|
| 231 |
-
# hf_hub_download часто возвращает HTTPError или FileNotFoundError внутри
|
| 232 |
if "404" in str(e) or isinstance(e, FileNotFoundError):
|
| 233 |
logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск скачивания этого файла.")
|
| 234 |
else:
|
| 235 |
-
# Логируем другие, возможно, более серьезные ошибки
|
| 236 |
logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True)
|
| 237 |
logging.info(f"Скачивание файлов с HF завершено. Скачано файлов: {downloaded_files_count}/{len(files_to_download)}.")
|
| 238 |
except RepositoryNotFoundError:
|
| 239 |
-
# Эта ошибка ловится и выше, но может возникнуть при первой проверке репо, если раскомментировать
|
| 240 |
logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание не выполнено.")
|
| 241 |
except Exception as e:
|
| 242 |
-
# Общая ошибка, если не удалось даже инициализировать Api() или что-то глобальное
|
| 243 |
logging.error(f"Общая ошибка при попытке скачивания с Hugging Face: {e}", exc_info=True)
|
| 244 |
-
# Не прерываем работу приложения, будем использовать локальные файлы, если они есть
|
| 245 |
|
| 246 |
|
| 247 |
def periodic_backup():
|
| 248 |
-
|
| 249 |
-
backup_interval = 1800 # 30 минут
|
| 250 |
logging.info(f"Настройка периодического резервного копирования каждые {backup_interval} секунд.")
|
| 251 |
while True:
|
| 252 |
time.sleep(backup_interval)
|
| 253 |
logging.info("Запуск периодического резервного копирования...")
|
| 254 |
-
upload_db_to_hf()
|
| 255 |
logging.info("Периодическое резервное копирование завершено.")
|
| 256 |
|
| 257 |
|
| 258 |
-
# --- Маршруты Flask ---
|
| 259 |
-
|
| 260 |
@app.route('/')
|
| 261 |
def catalog():
|
| 262 |
-
"""Главная страница каталога товаров."""
|
| 263 |
data = load_data()
|
| 264 |
products = data.get('products', [])
|
| 265 |
categories = data.get('categories', [])
|
| 266 |
is_authenticated = 'user' in session
|
| 267 |
|
| 268 |
-
# Убираем артефакты {/**/} из HTML шаблона
|
| 269 |
catalog_html = '''
|
| 270 |
<!DOCTYPE html>
|
| 271 |
<html lang="ru">
|
|
@@ -277,18 +222,17 @@ def catalog():
|
|
| 277 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 278 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
|
| 279 |
<style>
|
| 280 |
-
/* Общие стили */
|
| 281 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 282 |
body { font-family: 'Poppins', sans-serif; background: #f0f9f4; color: #2d332f; line-height: 1.6; transition: background 0.3s, color 0.3s; }
|
| 283 |
body.dark-mode { background: #1a2b26; color: #c8d8d3; }
|
| 284 |
.container { max-width: 1300px; margin: 0 auto; padding: 20px; }
|
| 285 |
.header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid #d1e7dd; }
|
| 286 |
body.dark-mode .header { border-bottom-color: #2c4a41; }
|
| 287 |
-
.header h1 { font-size: 1.8rem; font-weight: 600; color: #1C6758; }
|
| 288 |
.auth-links { display: flex; gap: 15px; align-items: center; }
|
| 289 |
-
.auth-links a { color: #3D8361; text-decoration: none; font-weight: 500; }
|
| 290 |
.auth-links a:hover { text-decoration: underline; }
|
| 291 |
-
body.dark-mode .auth-links a { color: #55a683; }
|
| 292 |
.auth-links span { font-weight: 500; }
|
| 293 |
body.dark-mode .auth-links span { color: #b0c8c1;}
|
| 294 |
.theme-toggle { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #7a8d85; transition: color 0.3s ease; }
|
|
@@ -298,19 +242,17 @@ def catalog():
|
|
| 298 |
.store-address { padding: 15px; text-align: center; background-color: #ffffff; margin: 20px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); font-size: 1rem; color: #44524c; }
|
| 299 |
body.dark-mode .store-address { background-color: #253f37; color: #b0c8c1; }
|
| 300 |
|
| 301 |
-
/* Фильтры и поиск */
|
| 302 |
.filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
| 303 |
.search-container { margin: 20px 0; text-align: center; }
|
| 304 |
#search-input { width: 90%; max-width: 600px; padding: 12px 18px; font-size: 1rem; border: 1px solid #d1e7dd; border-radius: 25px; outline: none; box-shadow: 0 2px 5px rgba(0,0,0,0.05); transition: all 0.3s ease; }
|
| 305 |
body.dark-mode #search-input { background-color: #253f37; border-color: #2c4a41; color: #c8d8d3; }
|
| 306 |
-
#search-input:focus { border-color: #1C6758; box-shadow: 0 0 0 3px rgba(28, 103, 88, 0.2); }
|
| 307 |
-
body.dark-mode #search-input:focus { border-color: #3D8361; box-shadow: 0 0 0 3px rgba(61, 131, 97, 0.3); }
|
| 308 |
-
.category-filter { padding: 8px 16px; border: 1px solid #d1e7dd; 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; color: #1C6758; }
|
| 309 |
body.dark-mode .category-filter { background-color: #253f37; border-color: #2c4a41; color: #97b7ae; }
|
| 310 |
-
.category-filter.active, .category-filter:hover { background-color: #1C6758; color: white; border-color: #1C6758; box-shadow: 0 2px 10px rgba(28, 103, 88, 0.3); }
|
| 311 |
-
body.dark-mode .category-filter.active, body.dark-mode .category-filter:hover { background-color: #3D8361; border-color: #3D8361; color: #1a2b26; box-shadow: 0 2px 10px rgba(61, 131, 97, 0.4); }
|
| 312 |
|
| 313 |
-
/* Сетка товаров */
|
| 314 |
.products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; padding: 10px; }
|
| 315 |
@media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } }
|
| 316 |
.product { background: #fff; border-radius: 15px; padding: 0; 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; justify-content: space-between; height: 100%; border: 1px solid #e1f0e9;}
|
|
@@ -323,23 +265,21 @@ def catalog():
|
|
| 323 |
.product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
|
| 324 |
.product h2 { font-size: 1.1rem; font-weight: 600; margin: 0 0 8px 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #2d332f; }
|
| 325 |
body.dark-mode .product h2 { color: #c8d8d3; }
|
| 326 |
-
.product-price { font-size: 1.2rem; color: #1C6758; font-weight: 700; text-align: center; margin: 5px 0; }
|
| 327 |
-
body.dark-mode .product-price { color: #55a683; }
|
| 328 |
.product-description { font-size: 0.85rem; color: #7a8d85; text-align: center; margin-bottom: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 329 |
body.dark-mode .product-description { color: #8aa39a; }
|
| 330 |
.product-actions { padding: 0 15px 15px 15px; display: flex; flex-direction: column; gap: 8px; }
|
| 331 |
-
.product-button { display: block; width: 100%; padding: 10px; border: none; border-radius: 8px; background-color: #1C6758; color: white; font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); text-align: center; text-decoration: none; }
|
| 332 |
-
.product-button:hover { background-color: #164B41; box-shadow: 0 4px 15px rgba(22, 75, 65, 0.4); transform: translateY(-2px); }
|
| 333 |
.product-button i { margin-right: 5px; }
|
| 334 |
|
| 335 |
-
|
| 336 |
-
.add-to-cart { background-color: #38a169; } /* Зеленый для корзины (можно оставить) */
|
| 337 |
.add-to-cart:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
|
| 338 |
-
#cart-button { position: fixed; bottom: 25px; right: 25px; background-color: #1C6758; 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(28, 103, 88, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; }
|
| 339 |
#cart-button .fa-shopping-cart { margin-right: 0; }
|
| 340 |
-
#cart-button span { position: absolute; top: -5px; right: -5px; background-color: #38a169; color: white; border-radius: 50%; padding: 2px 6px; font-size: 0.7rem; font-weight: bold; }
|
| 341 |
|
| 342 |
-
/* Модальные окна */
|
| 343 |
.modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); backdrop-filter: blur(5px); overflow-y: auto; }
|
| 344 |
.modal-content { background: #f8fcfb; 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; position: relative; }
|
| 345 |
body.dark-mode .modal-content { background: #253f37; color: #c8d8d3; }
|
|
@@ -348,8 +288,8 @@ def catalog():
|
|
| 348 |
.close:hover { color: #333; }
|
| 349 |
body.dark-mode .close { color: #7a8d85; }
|
| 350 |
body.dark-mode .close:hover { color: #b0c8c1; }
|
| 351 |
-
.modal-content h2 { margin-top: 0; margin-bottom: 20px; color: #1C6758; display: flex; align-items: center; gap: 10px;}
|
| 352 |
-
body.dark-mode .modal-content h2 { color: #55a683; }
|
| 353 |
.cart-item { display: grid; grid-template-columns: auto 1fr auto auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #d1e7dd; }
|
| 354 |
body.dark-mode .cart-item { border-bottom-color: #2c4a41; }
|
| 355 |
.cart-item:last-child { border-bottom: none; }
|
|
@@ -359,7 +299,7 @@ def catalog():
|
|
| 359 |
.cart-item-price { font-size: 0.9rem; color: #44524c; }
|
| 360 |
body.dark-mode .cart-item-price { color: #8aa39a; }
|
| 361 |
.cart-item-total { font-weight: bold; text-align: right; grid-column: 3; }
|
| 362 |
-
.cart-item-remove { grid-column: 4; background:none; border:none; color:#f56565; cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; }
|
| 363 |
.cart-item-remove:hover { color: #c53030; }
|
| 364 |
.quantity-input, .color-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #d1e7dd; border-radius: 8px; font-size: 1rem; margin: 10px 0; box-sizing: border-box; }
|
| 365 |
body.dark-mode .quantity-input, body.dark-mode .color-select { background-color: #1a2b26; border-color: #2c4a41; color: #c8d8d3; }
|
|
@@ -367,14 +307,13 @@ def catalog():
|
|
| 367 |
body.dark-mode .cart-summary { border-top-color: #2c4a41; }
|
| 368 |
.cart-summary strong { font-size: 1.2rem; }
|
| 369 |
.cart-actions { margin-top: 25px; display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
| 370 |
-
.cart-actions .product-button { width: auto; flex-grow: 1; }
|
| 371 |
-
.clear-cart { background-color: #7a8d85; }
|
| 372 |
.clear-cart:hover { background-color: #5e6e68; box-shadow: 0 4px 15px rgba(94, 110, 104, 0.4); }
|
| 373 |
-
.order-button { background-color: #38a169; }
|
| 374 |
.order-button:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
|
| 375 |
|
| 376 |
-
|
| 377 |
-
.notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: #38a169; color: white; padding: 10px 20px; border-radius: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.2); z-index: 1002; opacity: 0; transition: opacity 0.5s ease; font-size: 0.9rem;} /* Уведомление - ярко-зеленое */
|
| 378 |
.notification.show { opacity: 1;}
|
| 379 |
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #5e6e68; }
|
| 380 |
body.dark-mode .no-results-message { color: #8aa39a; }
|
|
@@ -444,14 +383,12 @@ def catalog():
|
|
| 444 |
</div>
|
| 445 |
</div>
|
| 446 |
{% endfor %}
|
| 447 |
-
{# Сообщение, если нет товаров ПОСЛЕ фильтрации, будет добавлено через JS #}
|
| 448 |
{% if not products %}
|
| 449 |
<p class="no-results-message">Товары пока не добавл��ны.</p>
|
| 450 |
{% endif %}
|
| 451 |
</div>
|
| 452 |
</div>
|
| 453 |
|
| 454 |
-
<!-- Product Modal -->
|
| 455 |
<div id="productModal" class="modal">
|
| 456 |
<div class="modal-content">
|
| 457 |
<span class="close" onclick="closeModal('productModal')" aria-label="Закрыть">×</span>
|
|
@@ -459,7 +396,6 @@ def catalog():
|
|
| 459 |
</div>
|
| 460 |
</div>
|
| 461 |
|
| 462 |
-
<!-- Quantity and Color Modal -->
|
| 463 |
<div id="quantityModal" class="modal">
|
| 464 |
<div class="modal-content">
|
| 465 |
<span class="close" onclick="closeModal('quantityModal')" aria-label="Закрыть">×</span>
|
|
@@ -472,7 +408,6 @@ def catalog():
|
|
| 472 |
</div>
|
| 473 |
</div>
|
| 474 |
|
| 475 |
-
<!-- Cart Modal -->
|
| 476 |
<div id="cartModal" class="modal">
|
| 477 |
<div class="modal-content">
|
| 478 |
<span class="close" onclick="closeModal('cartModal')" aria-label="Закрыть">×</span>
|
|
@@ -492,18 +427,15 @@ def catalog():
|
|
| 492 |
</div>
|
| 493 |
</div>
|
| 494 |
|
| 495 |
-
<!-- Cart Floating Button -->
|
| 496 |
<button id="cart-button" onclick="openCartModal()" aria-label="Открыть корзину">
|
| 497 |
<i class="fas fa-shopping-cart"></i>
|
| 498 |
<span id="cart-count">0</span>
|
| 499 |
</button>
|
| 500 |
|
| 501 |
-
<!-- Notification Placeholder -->
|
| 502 |
<div id="notification-placeholder"></div>
|
| 503 |
|
| 504 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
|
| 505 |
<script>
|
| 506 |
-
// --- Global Variables ---
|
| 507 |
const products = {{ products|tojson }};
|
| 508 |
const repoId = '{{ repo_id }}';
|
| 509 |
const currencyCode = '{{ currency_code }}';
|
|
@@ -511,7 +443,6 @@ def catalog():
|
|
| 511 |
let selectedProductIndex = null;
|
| 512 |
let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
|
| 513 |
|
| 514 |
-
// --- Theme ---
|
| 515 |
function toggleTheme() {
|
| 516 |
document.body.classList.toggle('dark-mode');
|
| 517 |
const icon = document.querySelector('.theme-toggle i');
|
|
@@ -530,7 +461,6 @@ def catalog():
|
|
| 530 |
}
|
| 531 |
}
|
| 532 |
|
| 533 |
-
// --- Auto Login ---
|
| 534 |
function attemptAutoLogin() {
|
| 535 |
const storedUser = localStorage.getItem('soolaUser');
|
| 536 |
if (storedUser && !isAuthenticated) {
|
|
@@ -556,7 +486,6 @@ def catalog():
|
|
| 556 |
}
|
| 557 |
}
|
| 558 |
|
| 559 |
-
// --- Modals ---
|
| 560 |
function openModal(index) {
|
| 561 |
loadProductDetails(index);
|
| 562 |
const modal = document.getElementById('productModal');
|
|
@@ -607,12 +536,11 @@ def catalog():
|
|
| 607 |
pagination: { el: '.swiper-pagination', clickable: true },
|
| 608 |
navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
|
| 609 |
zoom: { maxRatio: 3, containerClass: 'swiper-zoom-container' },
|
| 610 |
-
autoplay: { delay: 5000, disableOnInteraction: true, },
|
| 611 |
});
|
| 612 |
}
|
| 613 |
}
|
| 614 |
|
| 615 |
-
// --- Cart Logic ---
|
| 616 |
function openQuantityModal(index) {
|
| 617 |
if (!isAuthenticated) {
|
| 618 |
alert('Пожалуйста, войдите в систему, чтобы добавить товар в корзину.');
|
|
@@ -629,7 +557,7 @@ def catalog():
|
|
| 629 |
|
| 630 |
const colorSelect = document.getElementById('colorSelect');
|
| 631 |
const colorLabel = document.querySelector('label[for="colorSelect"]');
|
| 632 |
-
colorSelect.innerHTML = '';
|
| 633 |
|
| 634 |
const validColors = product.colors ? product.colors.filter(c => c && c.trim() !== "") : [];
|
| 635 |
|
|
@@ -757,17 +685,16 @@ def catalog():
|
|
| 757 |
function removeFromCart(itemId) {
|
| 758 |
cart = cart.filter(item => item.id !== itemId);
|
| 759 |
localStorage.setItem('soolaCart', JSON.stringify(cart));
|
| 760 |
-
openCartModal();
|
| 761 |
-
updateCartButton();
|
| 762 |
}
|
| 763 |
|
| 764 |
function clearCart() {
|
| 765 |
if (confirm("Вы уверены, что хотите очистить корзину?")) {
|
| 766 |
cart = [];
|
| 767 |
localStorage.removeItem('soolaCart');
|
| 768 |
-
openCartModal();
|
| 769 |
updateCartButton();
|
| 770 |
-
// closeModal('cartModal'); // Можно закрыть или оставить открытой
|
| 771 |
}
|
| 772 |
}
|
| 773 |
|
|
@@ -777,41 +704,37 @@ def catalog():
|
|
| 777 |
return;
|
| 778 |
}
|
| 779 |
let total = 0;
|
| 780 |
-
let orderText = "Новый Заказ от Soola Cosmetics:%0A%0A";
|
| 781 |
cart.forEach((item, index) => {
|
| 782 |
const itemTotal = item.price * item.quantity;
|
| 783 |
total += itemTotal;
|
| 784 |
const colorText = item.color !== 'N/A' ? ` (Цвет: ${item.color})` : '';
|
| 785 |
-
// Форматирование строки заказа
|
| 786 |
orderText += `${index + 1}. ${item.name}${colorText} - ${item.price.toFixed(2)} ${currencyCode} × ${item.quantity} = ${itemTotal.toFixed(2)} ${currencyCode}%0A`;
|
| 787 |
});
|
| 788 |
-
orderText += `%0A*Итого: ${total.toFixed(2)} ${currencyCode}*%0A%0A`;
|
| 789 |
|
| 790 |
-
//
|
| 791 |
const userInfo = {{ session.get('user_info', {})|tojson }};
|
| 792 |
if (userInfo && userInfo.login) {
|
| 793 |
-
|
|
|
|
| 794 |
orderText += `Логин: ${userInfo.login}%0A`;
|
| 795 |
-
orderText += `Страна: ${userInfo.
|
| 796 |
-
orderText += `Город: ${userInfo.
|
| 797 |
} else {
|
| 798 |
orderText += `Заказчик: (Не авторизован)%0A`;
|
| 799 |
}
|
|
|
|
| 800 |
|
| 801 |
-
// Добавляем текущую дату и время
|
| 802 |
const now = new Date();
|
| 803 |
-
const dateTimeString = now.toLocaleString('ru-RU');
|
| 804 |
orderText += `%0AДата заказа: ${dateTimeString}`;
|
| 805 |
|
| 806 |
-
|
| 807 |
-
const whatsappNumber = "996997703090"; // Указываем номер без '+' и пробелов
|
| 808 |
-
// Формируем URL для WhatsApp API
|
| 809 |
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
|
| 810 |
-
// Открываем WhatsApp в новой вкладке
|
| 811 |
window.open(whatsappUrl, '_blank');
|
| 812 |
}
|
| 813 |
|
| 814 |
-
// --- Filtering and Search ---
|
| 815 |
function filterProducts() {
|
| 816 |
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
|
| 817 |
const activeCategoryButton = document.querySelector('.category-filter.active');
|
|
@@ -819,7 +742,6 @@ def catalog():
|
|
| 819 |
const grid = document.getElementById('products-grid');
|
| 820 |
let visibleProducts = 0;
|
| 821 |
|
| 822 |
-
// Удаляем старое сообщение "не найдено", если оно есть
|
| 823 |
const existingNoResults = grid.querySelector('.no-results-message');
|
| 824 |
if (existingNoResults) existingNoResults.remove();
|
| 825 |
|
|
@@ -839,7 +761,6 @@ def catalog():
|
|
| 839 |
}
|
| 840 |
});
|
| 841 |
|
| 842 |
-
// Показать сообщение "товары не найдены", если 0 видимых товаров и был поиск/фильтр
|
| 843 |
if (visibleProducts === 0 && (searchTerm || activeCategory !== 'all')) {
|
| 844 |
const p = document.createElement('p');
|
| 845 |
p.className = 'no-results-message';
|
|
@@ -863,7 +784,6 @@ def catalog():
|
|
| 863 |
});
|
| 864 |
}
|
| 865 |
|
| 866 |
-
// --- Notifications ---
|
| 867 |
function showNotification(message, duration = 3000) {
|
| 868 |
const placeholder = document.getElementById('notification-placeholder');
|
| 869 |
if (!placeholder) return;
|
|
@@ -873,31 +793,26 @@ def catalog():
|
|
| 873 |
notification.textContent = message;
|
| 874 |
placeholder.appendChild(notification);
|
| 875 |
|
| 876 |
-
// Trigger transition
|
| 877 |
setTimeout(() => { notification.classList.add('show'); }, 10);
|
| 878 |
|
| 879 |
-
// Remove after duration
|
| 880 |
setTimeout(() => {
|
| 881 |
notification.classList.remove('show');
|
| 882 |
-
setTimeout(() => { notification.remove(); }, 500);
|
| 883 |
}, duration);
|
| 884 |
}
|
| 885 |
|
| 886 |
-
// --- Event Listeners and Initial Setup ---
|
| 887 |
document.addEventListener('DOMContentLoaded', () => {
|
| 888 |
applyInitialTheme();
|
| 889 |
-
attemptAutoLogin();
|
| 890 |
-
updateCartButton();
|
| 891 |
-
setupFilters();
|
| 892 |
|
| 893 |
-
// Global click listener for closing modals
|
| 894 |
window.addEventListener('click', function(event) {
|
| 895 |
if (event.target.classList.contains('modal')) {
|
| 896 |
closeModal(event.target.id);
|
| 897 |
}
|
| 898 |
});
|
| 899 |
|
| 900 |
-
// Global keydown listener for closing modals with Escape key
|
| 901 |
window.addEventListener('keydown', function(event) {
|
| 902 |
if (event.key === 'Escape') {
|
| 903 |
document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
|
|
@@ -918,14 +833,13 @@ def catalog():
|
|
| 918 |
repo_id=REPO_ID,
|
| 919 |
is_authenticated=is_authenticated,
|
| 920 |
store_address=STORE_ADDRESS,
|
| 921 |
-
session=session,
|
| 922 |
currency_code=CURRENCY_CODE
|
| 923 |
)
|
| 924 |
|
| 925 |
|
| 926 |
@app.route('/product/<int:index>')
|
| 927 |
def product_detail(index):
|
| 928 |
-
"""Отдает HTML с деталями одного продукта для модального окна."""
|
| 929 |
data = load_data()
|
| 930 |
products = data.get('products', [])
|
| 931 |
is_authenticated = 'user' in session
|
|
@@ -935,18 +849,15 @@ def product_detail(index):
|
|
| 935 |
logging.warning(f"Попытка доступа к несуществующему продукту с индексом {index}")
|
| 936 |
return "Товар не найден", 404
|
| 937 |
|
| 938 |
-
# HTML для деталей продукта с темно-зелеными акцентами
|
| 939 |
detail_html = '''
|
| 940 |
-
{# Используем Jinja комментарий #}
|
| 941 |
<div style="padding: 10px;">
|
| 942 |
-
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #1C6758;">{{ product['name'] }}</h2>
|
| 943 |
-
{# Swiper Slider for Photos #}
|
| 944 |
<div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
|
| 945 |
<div class="swiper-wrapper">
|
| 946 |
{% if product.get('photos') and product['photos']|length > 0 %}
|
| 947 |
{% for photo in product['photos'] %}
|
| 948 |
<div class="swiper-slide" style="display: flex; justify-content: center; align-items: center; padding: 10px;">
|
| 949 |
-
<div class="swiper-zoom-container">
|
| 950 |
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}"
|
| 951 |
alt="{{ product['name'] }} - фото {{ loop.index }}"
|
| 952 |
style="max-width: 100%; max-height: 400px; object-fit: contain; display: block; margin: auto; cursor: grab;">
|
|
@@ -959,26 +870,23 @@ def product_detail(index):
|
|
| 959 |
</div>
|
| 960 |
{% endif %}
|
| 961 |
</div>
|
| 962 |
-
{# Элементы управления Swiper (показываем только если фото больше 1) #}
|
| 963 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 964 |
<div class="swiper-pagination" style="position: relative; bottom: 5px;"></div>
|
| 965 |
-
<div class="swiper-button-next" style="color: #1C6758;"></div>
|
| 966 |
-
<div class="swiper-button-prev" style="color: #1C6758;"></div>
|
| 967 |
{% endif %}
|
| 968 |
</div>
|
| 969 |
|
| 970 |
-
{# Product Details #}
|
| 971 |
<div style="margin-top: 20px; font-size: 1rem; line-height: 1.7;">
|
| 972 |
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
|
| 973 |
{% if is_authenticated %}
|
| 974 |
-
<p style="font-size: 1.2rem; font-weight: bold; color: #1C6758;"><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 975 |
{% else %}
|
| 976 |
-
<p><strong>Цена:</strong> <a href="{{ url_for('login') }}" style="color: #3D8361; text-decoration: underline;">Доступна после входа</a></p>
|
| 977 |
{% endif %}
|
| 978 |
-
{
|
| 979 |
-
<p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\n', '<br>')|safe }}</p>
|
| 980 |
{% set colors = product.get('colors', []) %}
|
| 981 |
-
{% if colors and colors|select('ne', '')|list|length > 0 %}
|
| 982 |
<p><strong>Доступные цвета/варианты:</strong> {{ colors|select('ne', '')|join(', ') }}</p>
|
| 983 |
{% endif %}
|
| 984 |
</div>
|
|
@@ -992,9 +900,6 @@ def product_detail(index):
|
|
| 992 |
currency_code=CURRENCY_CODE
|
| 993 |
)
|
| 994 |
|
| 995 |
-
# --- Маршруты аутентификации ---
|
| 996 |
-
|
| 997 |
-
# Шаблон для страницы входа с темно-зелеными акцентами
|
| 998 |
LOGIN_TEMPLATE = '''
|
| 999 |
<!DOCTYPE html>
|
| 1000 |
<html lang="ru">
|
|
@@ -1004,16 +909,16 @@ LOGIN_TEMPLATE = '''
|
|
| 1004 |
<title>Вход - Soola Cosmetics</title>
|
| 1005 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 1006 |
<style>
|
| 1007 |
-
body { font-family: 'Poppins', sans-serif; background: linear-gradient(135deg, #d1e7dd, #e9f5f0); display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
|
| 1008 |
.container { max-width: 400px; width: 100%; background: #fff; padding: 30px 40px; border-radius: 15px; box-shadow: 0 5px 20px rgba(0,0,0,0.1); text-align: center; }
|
| 1009 |
-
h2 { color: #1C6758; margin-bottom: 25px; font-weight: 600; }
|
| 1010 |
label { display: block; text-align: left; margin: 15px 0 5px; font-weight: 500; color: #44524c; }
|
| 1011 |
input[type="text"], input[type="password"] { width: 100%; padding: 12px; margin-bottom: 15px; border: 1px solid #c4d9d1; border-radius: 8px; box-sizing: border-box; font-size: 1rem; }
|
| 1012 |
-
input:focus { border-color: #1C6758; outline: none; box-shadow: 0 0 0 2px rgba(28, 103, 88, 0.2); }
|
| 1013 |
-
button { width: 100%; padding: 12px; background-color: #1C6758; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1rem; font-weight: 600; transition: background-color 0.3s ease; margin-top: 10px; }
|
| 1014 |
-
button:hover { background-color: #164B41; }
|
| 1015 |
-
.error { color: #721c24; background-color: #f8d7da; border: 1px solid #f5c6cb; padding: 10px; border-radius: 8px; margin-bottom: 15px; font-size: 0.9rem; text-align: left;}
|
| 1016 |
-
.back-link { display: inline-block; margin-top: 20px; color: #3D8361; text-decoration: none; font-size: 0.9rem; }
|
| 1017 |
.back-link:hover { text-decoration: underline; }
|
| 1018 |
</style>
|
| 1019 |
</head>
|
|
@@ -1038,7 +943,6 @@ LOGIN_TEMPLATE = '''
|
|
| 1038 |
|
| 1039 |
@app.route('/login', methods=['GET', 'POST'])
|
| 1040 |
def login():
|
| 1041 |
-
"""Страница входа пользователя."""
|
| 1042 |
if request.method == 'POST':
|
| 1043 |
login = request.form.get('login')
|
| 1044 |
password = request.form.get('password')
|
|
@@ -1047,11 +951,6 @@ def login():
|
|
| 1047 |
|
| 1048 |
users = load_users()
|
| 1049 |
|
| 1050 |
-
# Важно: В реальном приложении используйте хеширование паролей!
|
| 1051 |
-
# Пример с хешированием (нужна библиотека passlib):
|
| 1052 |
-
# from passlib.hash import pbkdf2_sha256
|
| 1053 |
-
# if login in users and pbkdf2_sha256.verify(password, users[login].get('password_hash')):
|
| 1054 |
-
# Либо простая проверка для демонстрации:
|
| 1055 |
if login in users and users[login].get('password') == password:
|
| 1056 |
user_info = users[login]
|
| 1057 |
session['user'] = login
|
|
@@ -1078,13 +977,11 @@ def login():
|
|
| 1078 |
error_message = "Неверный логин или пароль."
|
| 1079 |
return render_template_string(LOGIN_TEMPLATE, error=error_message), 401
|
| 1080 |
|
| 1081 |
-
# GET запрос
|
| 1082 |
return render_template_string(LOGIN_TEMPLATE, error=None)
|
| 1083 |
|
| 1084 |
|
| 1085 |
@app.route('/auto_login', methods=['POST'])
|
| 1086 |
def auto_login():
|
| 1087 |
-
"""Попытка автоматического входа по логину из localStorage."""
|
| 1088 |
data = request.get_json()
|
| 1089 |
if not data or 'login' not in data:
|
| 1090 |
logging.warning("Запрос auto_login без данных или логина.")
|
|
@@ -1097,8 +994,6 @@ def auto_login():
|
|
| 1097 |
|
| 1098 |
users = load_users()
|
| 1099 |
if login in users:
|
| 1100 |
-
# Пароль не проверяем при авто-логине, т.к. он не передается
|
| 1101 |
-
# Доверяем только наличию логина в localStorage (умеренная безопасность)
|
| 1102 |
user_info = users[login]
|
| 1103 |
session['user'] = login
|
| 1104 |
session['user_info'] = {
|
|
@@ -1112,19 +1007,15 @@ def auto_login():
|
|
| 1112 |
return "OK", 200
|
| 1113 |
else:
|
| 1114 |
logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}.")
|
| 1115 |
-
|
| 1116 |
-
# Просто возвращаем ошибку, чтобы JS удалил невалидный логин из localStorage
|
| 1117 |
-
return "Ошибка авто-входа", 400 # Или 403 Forbidden
|
| 1118 |
|
| 1119 |
@app.route('/logout')
|
| 1120 |
def logout():
|
| 1121 |
-
"""Выход пользователя из системы."""
|
| 1122 |
logged_out_user = session.get('user')
|
| 1123 |
session.pop('user', None)
|
| 1124 |
session.pop('user_info', None)
|
| 1125 |
if logged_out_user:
|
| 1126 |
logging.info(f"Пользователь {logged_out_user} вышел из системы.")
|
| 1127 |
-
# Ответ с JS для очистки localStorage и редиректом
|
| 1128 |
logout_response_html = '''
|
| 1129 |
<!DOCTYPE html><html><head><title>Выход...</title></head><body>
|
| 1130 |
<script>
|
|
@@ -1136,8 +1027,6 @@ def logout():
|
|
| 1136 |
'''
|
| 1137 |
return logout_response_html
|
| 1138 |
|
| 1139 |
-
# --- Админ-панель ---
|
| 1140 |
-
# Шаблон админ-панели с темно-зелеными акцентами
|
| 1141 |
ADMIN_TEMPLATE = '''
|
| 1142 |
<!DOCTYPE html>
|
| 1143 |
<html lang="ru">
|
|
@@ -1148,28 +1037,28 @@ ADMIN_TEMPLATE = '''
|
|
| 1148 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 1149 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 1150 |
<style>
|
| 1151 |
-
body { font-family: 'Poppins', sans-serif; background-color: #e9f5f0; color: #2d332f; padding: 20px; line-height: 1.6; }
|
| 1152 |
.container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }
|
| 1153 |
.header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #d1e7dd; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
|
| 1154 |
-
h1, h2, h3 { font-weight: 600; color: #1C6758; margin-bottom: 15px; }
|
| 1155 |
h1 { font-size: 1.8rem; }
|
| 1156 |
h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
|
| 1157 |
-
h3 { font-size: 1.2rem; color: #164B41; margin-top: 20px; }
|
| 1158 |
.section { margin-bottom: 30px; padding: 20px; background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; }
|
| 1159 |
form { margin-bottom: 20px; }
|
| 1160 |
label { font-weight: 500; margin-top: 10px; display: block; color: #44524c; font-size: 0.9rem;}
|
| 1161 |
input[type="text"], input[type="number"], input[type="password"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #c4d9d1; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; transition: border-color 0.3s ease; }
|
| 1162 |
-
input:focus, textarea:focus, select:focus { border-color: #1C6758; outline: none; box-shadow: 0 0 0 2px rgba(28, 103, 88, 0.1); }
|
| 1163 |
textarea { min-height: 80px; resize: vertical; }
|
| 1164 |
input[type="file"] { padding: 8px; background-color: #f0f9f4; cursor: pointer; border: 1px solid #c4d9d1;}
|
| 1165 |
input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #e0f0e9; border: 1px solid #c4d9d1; cursor: pointer; margin-right: 10px;}
|
| 1166 |
-
button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #1C6758; color: white; font-weight: 500; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 15px; font-size: 0.95rem; display: inline-flex; align-items: center; gap: 5px; text-decoration: none; line-height: 1.2;}
|
| 1167 |
-
button:hover, .button:hover { background-color: #164B41; }
|
| 1168 |
button:active, .button:active { transform: scale(0.98); }
|
| 1169 |
button[type="submit"] { min-width: 120px; justify-content: center; }
|
| 1170 |
-
.delete-button { background-color: #f56565; }
|
| 1171 |
.delete-button:hover { background-color: #e53e3e; }
|
| 1172 |
-
.add-button { background-color: #38a169; }
|
| 1173 |
.add-button:hover { background-color: #2f855a; }
|
| 1174 |
.item-list { display: grid; gap: 20px; }
|
| 1175 |
.item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #e1f0e9; }
|
|
@@ -1177,44 +1066,40 @@ ADMIN_TEMPLATE = '''
|
|
| 1177 |
.item strong { color: #2d332f; }
|
| 1178 |
.item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
|
| 1179 |
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; }
|
| 1180 |
-
/* Кнопка редактирования - основной темно-зеленый */
|
| 1181 |
.item-actions button:not(.delete-button) { background-color: #1C6758; }
|
| 1182 |
.item-actions button:not(.delete-button):hover { background-color: #164B41; }
|
| 1183 |
-
.edit-form-container { margin-top: 15px; padding: 20px; background: #f0f9f4; border: 1px dashed #c4d9d1; border-radius: 6px; display: none;
|
| 1184 |
details { background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; margin-bottom: 20px; }
|
| 1185 |
-
details > summary { cursor: pointer; font-weight: 600; color: #164B41; display: block; padding: 15px; border-bottom: 1px solid #d1e7dd; list-style: none;
|
| 1186 |
-
details > summary::after { content: '\\f078';
|
| 1187 |
details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
|
| 1188 |
details[open] > summary { border-bottom: 1px solid #d1e7dd; }
|
| 1189 |
details .form-content { padding: 20px; }
|
| 1190 |
.color-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
| 1191 |
.color-input-group input { flex-grow: 1; margin: 0; }
|
| 1192 |
-
.remove-color-btn { background-color: #f56565; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; }
|
| 1193 |
.remove-color-btn:hover { background-color: #e53e3e; }
|
| 1194 |
-
/* Кнопка "Добавить поле цвета" - синяя для контраста */
|
| 1195 |
.add-color-btn { background-color: #63b3ed; }
|
| 1196 |
.add-color-btn:hover { background-color: #4299e1; }
|
| 1197 |
.photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #d1e7dd; object-fit: cover;}
|
| 1198 |
.sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
|
| 1199 |
-
/* Кнопка "Скачать с HF" - более мягкий цвет, например, серый */
|
| 1200 |
.download-hf-button { background-color: #7a8d85; }
|
| 1201 |
.download-hf-button:hover { background-color: #5e6e68; }
|
| 1202 |
.flex-container { display: flex; flex-wrap: wrap; gap: 20px; }
|
| 1203 |
-
.flex-item { flex: 1; min-width: 350px;
|
| 1204 |
.message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;}
|
| 1205 |
-
.message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
|
| 1206 |
-
.message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
|
| 1207 |
-
.message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
|
| 1208 |
</style>
|
| 1209 |
</head>
|
| 1210 |
<body>
|
| 1211 |
<div class="container">
|
| 1212 |
<div class="header">
|
| 1213 |
<h1><i class="fas fa-tools"></i> Админ-панель Soola Cosmetics</h1>
|
| 1214 |
-
<a href="{{ url_for('catalog') }}" class="button" style="background-color: #3D8361;"><i class="fas fa-store"></i> Перейти в каталог</a>
|
| 1215 |
</div>
|
| 1216 |
|
| 1217 |
-
{# Сообщения об успехе/ошибке #}
|
| 1218 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 1219 |
{% if messages %}
|
| 1220 |
{% for category, message in messages %}
|
|
@@ -1227,10 +1112,10 @@ ADMIN_TEMPLATE = '''
|
|
| 1227 |
<h2><i class="fas fa-sync-alt"></i> Синхронизация с Hugging Face</h2>
|
| 1228 |
<div class="sync-buttons">
|
| 1229 |
<form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно загрузить локальные данные на сервер? Это перезапишет данные на сервере.');">
|
| 1230 |
-
<button type="submit" class="button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить на HF</button>
|
| 1231 |
</form>
|
| 1232 |
<form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно скачать данные с сервера? Это перезапишет ваши локальные файлы.');">
|
| 1233 |
-
<button type="submit" class="button download-hf-button" title="Скачать файлы с Hugging Face (перезапишет локальные)"><i class="fas fa-download"></i> Скачать с HF</button>
|
| 1234 |
</form>
|
| 1235 |
</div>
|
| 1236 |
<p style="font-size: 0.85rem; color: #5e6e68;">Резервное копирование на Hugging Face происходит автоматически каждые 30 м��нут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p>
|
|
@@ -1238,7 +1123,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1238 |
|
| 1239 |
|
| 1240 |
<div class="flex-container">
|
| 1241 |
-
<div class="flex-item">
|
| 1242 |
<div class="section">
|
| 1243 |
<h2><i class="fas fa-tags"></i> Управление категориями</h2>
|
| 1244 |
<details>
|
|
@@ -1248,7 +1133,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1248 |
<input type="hidden" name="action" value="add_category">
|
| 1249 |
<label for="add_category_name">Название новой категории:</label>
|
| 1250 |
<input type="text" id="add_category_name" name="category_name" required>
|
| 1251 |
-
<button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button>
|
| 1252 |
</form>
|
| 1253 |
</div>
|
| 1254 |
</details>
|
|
@@ -1262,7 +1147,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1262 |
<form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить категорию \'{{ category }}\'? Товары этой категории будут помечены как \'Без категории\'.');">
|
| 1263 |
<input type="hidden" name="action" value="delete_category">
|
| 1264 |
<input type="hidden" name="category_name" value="{{ category }}">
|
| 1265 |
-
<button type="submit" class="delete-button" style="padding: 5px 10px; font-size: 0.8rem; margin: 0;"><i class="fas fa-trash-alt"></i></button>
|
| 1266 |
</form>
|
| 1267 |
</div>
|
| 1268 |
{% endfor %}
|
|
@@ -1273,7 +1158,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1273 |
</div>
|
| 1274 |
</div>
|
| 1275 |
|
| 1276 |
-
<div class="flex-item">
|
| 1277 |
<div class="section">
|
| 1278 |
<h2><i class="fas fa-users"></i> Управление пользователями</h2>
|
| 1279 |
<details>
|
|
@@ -1294,7 +1179,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1294 |
<input type="text" id="country" name="country">
|
| 1295 |
<label for="city">Город:</label>
|
| 1296 |
<input type="text" id="city" name="city">
|
| 1297 |
-
<button type="submit" class="add-button"><i class="fas fa-save"></i> Сохранить пользователя</button>
|
| 1298 |
</form>
|
| 1299 |
</div>
|
| 1300 |
</details>
|
|
@@ -1311,9 +1196,8 @@ ADMIN_TEMPLATE = '''
|
|
| 1311 |
<form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя \'{{ login }}\'?');">
|
| 1312 |
<input type="hidden" name="action" value="delete_user">
|
| 1313 |
<input type="hidden" name="login" value="{{ login }}">
|
| 1314 |
-
<button type="submit" class="delete-button"><i class="fas fa-user-slash"></i> Удалить</button>
|
| 1315 |
</form>
|
| 1316 |
-
{# Можно добавить кнопку редактирования пользователя, если нужно #}
|
| 1317 |
</div>
|
| 1318 |
</div>
|
| 1319 |
{% endfor %}
|
|
@@ -1326,7 +1210,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1326 |
</div>
|
| 1327 |
|
| 1328 |
|
| 1329 |
-
<div class="section">
|
| 1330 |
<h2><i class="fas fa-box-open"></i> Управление товарами</h2>
|
| 1331 |
<details>
|
| 1332 |
<summary><i class="fas fa-plus-circle"></i> Добавить но��ый товар</summary>
|
|
@@ -1352,12 +1236,12 @@ ADMIN_TEMPLATE = '''
|
|
| 1352 |
<div id="add-color-inputs">
|
| 1353 |
<div class="color-input-group">
|
| 1354 |
<input type="text" name="colors" placeholder="Например: Розовый">
|
| 1355 |
-
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1356 |
</div>
|
| 1357 |
</div>
|
| 1358 |
-
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле для цвета/варианта</button>
|
| 1359 |
<br>
|
| 1360 |
-
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Добавить товар</button>
|
| 1361 |
</form>
|
| 1362 |
</div>
|
| 1363 |
</details>
|
|
@@ -1368,7 +1252,6 @@ ADMIN_TEMPLATE = '''
|
|
| 1368 |
{% for product in products %}
|
| 1369 |
<div class="item">
|
| 1370 |
<div style="display: flex; gap: 15px; align-items: flex-start;">
|
| 1371 |
-
{# Превью первого фото #}
|
| 1372 |
<div class="photo-preview" style="flex-shrink: 0;">
|
| 1373 |
{% if product.get('photos') %}
|
| 1374 |
<a href="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" target="_blank" title="Посмотреть первое фото">
|
|
@@ -1378,9 +1261,8 @@ ADMIN_TEMPLATE = '''
|
|
| 1378 |
<img src="https://via.placeholder.com/70x70.png?text=N/A" alt="Нет фото">
|
| 1379 |
{% endif %}
|
| 1380 |
</div>
|
| 1381 |
-
{# Информация о товаре #}
|
| 1382 |
<div style="flex-grow: 1;">
|
| 1383 |
-
<h3 style="margin-top: 0; margin-bottom: 5px; color: #2d332f;">{{ product['name'] }}</h3>
|
| 1384 |
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
|
| 1385 |
<p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 1386 |
<p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
|
|
@@ -1393,15 +1275,14 @@ ADMIN_TEMPLATE = '''
|
|
| 1393 |
</div>
|
| 1394 |
|
| 1395 |
<div class="item-actions">
|
| 1396 |
-
<button type="button" class="button" onclick="toggleEditForm('edit-form-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
|
| 1397 |
<form method="POST" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');">
|
| 1398 |
<input type="hidden" name="action" value="delete_product">
|
| 1399 |
<input type="hidden" name="index" value="{{ loop.index0 }}">
|
| 1400 |
-
<button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
|
| 1401 |
</form>
|
| 1402 |
</div>
|
| 1403 |
|
| 1404 |
-
{# Форма редактирования (скрыта по умолчанию) #}
|
| 1405 |
<div id="edit-form-{{ loop.index0 }}" class="edit-form-container">
|
| 1406 |
<h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
|
| 1407 |
<form method="POST" enctype="multipart/form-data">
|
|
@@ -1435,24 +1316,23 @@ ADMIN_TEMPLATE = '''
|
|
| 1435 |
{% set current_colors = product.get('colors', []) %}
|
| 1436 |
{% if current_colors and current_colors|select('ne', '')|list|length > 0 %}
|
| 1437 |
{% for color in current_colors %}
|
| 1438 |
-
{% if color.strip() %}
|
| 1439 |
<div class="color-input-group">
|
| 1440 |
<input type="text" name="colors" value="{{ color }}">
|
| 1441 |
-
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1442 |
</div>
|
| 1443 |
{% endif %}
|
| 1444 |
{% endfor %}
|
| 1445 |
{% else %}
|
| 1446 |
-
{# Добавляем одно пустое поле, если цветов нет #}
|
| 1447 |
<div class="color-input-group">
|
| 1448 |
<input type="text" name="colors" placeholder="Например: Красный">
|
| 1449 |
-
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1450 |
</div>
|
| 1451 |
{% endif %}
|
| 1452 |
</div>
|
| 1453 |
-
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('edit-color-inputs-{{ loop.index0 }}')"><i class="fas fa-palette"></i> Добавить поле для цвета</button>
|
| 1454 |
<br>
|
| 1455 |
-
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить изменения</button>
|
| 1456 |
</form>
|
| 1457 |
</div>
|
| 1458 |
</div>
|
|
@@ -1483,7 +1363,6 @@ ADMIN_TEMPLATE = '''
|
|
| 1483 |
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1484 |
`;
|
| 1485 |
container.appendChild(newInputGroup);
|
| 1486 |
-
// Установить фокус на новый инпут
|
| 1487 |
const newInput = newInputGroup.querySelector('input[name="colors"]');
|
| 1488 |
if (newInput) {
|
| 1489 |
newInput.focus();
|
|
@@ -1492,15 +1371,10 @@ ADMIN_TEMPLATE = '''
|
|
| 1492 |
}
|
| 1493 |
|
| 1494 |
function removeColorInput(button) {
|
| 1495 |
-
// Ищем ближайший родительский элемент с классом 'color-input-group'
|
| 1496 |
const group = button.closest('.color-input-group');
|
| 1497 |
if (group) {
|
| 1498 |
const container = group.parentNode;
|
| 1499 |
group.remove();
|
| 1500 |
-
// Опционально: если удалили последний, можно добавить новый пустой
|
| 1501 |
-
// if (container && container.children.length === 0) {
|
| 1502 |
-
// addColorInput(container.id);
|
| 1503 |
-
// }
|
| 1504 |
} else {
|
| 1505 |
console.warn("Не удалось найти родительский .color-input-group для кнопки удаления");
|
| 1506 |
}
|
|
@@ -1512,14 +1386,9 @@ ADMIN_TEMPLATE = '''
|
|
| 1512 |
|
| 1513 |
@app.route('/admin', methods=['GET', 'POST'])
|
| 1514 |
def admin():
|
| 1515 |
-
|
| 1516 |
-
# Здесь должна быть проверка прав администратора!
|
| 1517 |
-
# Пример: if session.get('user') != 'admin_login': return "Доступ запрещен", 403
|
| 1518 |
-
# Для простоты пока опускаем (В ПРОДАВКШЕНЕ ОБЯЗАТЕЛЬНО ДОБАВИТЬ!)
|
| 1519 |
-
if not session.get('user'): # Простейшая проверка - залогинен ли хоть кто-то
|
| 1520 |
flash("Требуется вход для доступа к админ-панели.", 'warning')
|
| 1521 |
return redirect(url_for('login'))
|
| 1522 |
-
# TODO: Добавить более строгую проверку роли администратора, если пользователей много
|
| 1523 |
|
| 1524 |
data = load_data()
|
| 1525 |
products = data.get('products', [])
|
|
@@ -1535,7 +1404,7 @@ def admin():
|
|
| 1535 |
category_name = request.form.get('category_name', '').strip()
|
| 1536 |
if category_name and category_name not in categories:
|
| 1537 |
categories.append(category_name)
|
| 1538 |
-
categories.sort()
|
| 1539 |
save_data(data)
|
| 1540 |
logging.info(f"Категория '{category_name}' добавлена.")
|
| 1541 |
flash(f"Категория '{category_name}' успешно добавлена.", 'success')
|
|
@@ -1550,7 +1419,6 @@ def admin():
|
|
| 1550 |
category_to_delete = request.form.get('category_name')
|
| 1551 |
if category_to_delete and category_to_delete in categories:
|
| 1552 |
categories.remove(category_to_delete)
|
| 1553 |
-
# Обновляем товары
|
| 1554 |
updated_count = 0
|
| 1555 |
for product in products:
|
| 1556 |
if product.get('category') == category_to_delete:
|
|
@@ -1574,7 +1442,7 @@ def admin():
|
|
| 1574 |
|
| 1575 |
if not name or not price_str:
|
| 1576 |
flash("Название и цена товара обязательны.", 'error')
|
| 1577 |
-
return redirect(url_for('admin'))
|
| 1578 |
|
| 1579 |
try:
|
| 1580 |
price = round(float(price_str), 2)
|
|
@@ -1597,7 +1465,6 @@ def admin():
|
|
| 1597 |
break
|
| 1598 |
if photo and photo.filename:
|
| 1599 |
try:
|
| 1600 |
-
# Создаем уникальное имя файла
|
| 1601 |
ext = os.path.splitext(photo.filename)[1]
|
| 1602 |
photo_filename = secure_filename(f"{name.replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
|
| 1603 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
|
@@ -1605,7 +1472,7 @@ def admin():
|
|
| 1605 |
logging.info(f"Загрузка фото {photo_filename} на HF для товара {name}...")
|
| 1606 |
api.upload_file(
|
| 1607 |
path_or_fileobj=temp_path,
|
| 1608 |
-
path_in_repo=f"photos/{photo_filename}",
|
| 1609 |
repo_id=REPO_ID,
|
| 1610 |
repo_type="dataset",
|
| 1611 |
token=HF_TOKEN_WRITE,
|
|
@@ -1620,7 +1487,6 @@ def admin():
|
|
| 1620 |
flash(f"Ошибка при загрузке фото {photo.filename}.", 'error')
|
| 1621 |
elif photo and not photo.filename:
|
| 1622 |
logging.warning("Получен пустой объект файла фото при добавлении товара.")
|
| 1623 |
-
# Удаляем временную папку, если она пуста
|
| 1624 |
try:
|
| 1625 |
if not os.listdir(uploads_dir):
|
| 1626 |
os.rmdir(uploads_dir)
|
|
@@ -1634,7 +1500,6 @@ def admin():
|
|
| 1634 |
'photos': photos_list, 'colors': colors
|
| 1635 |
}
|
| 1636 |
products.append(new_product)
|
| 1637 |
-
# Сортируем продукты по имени после добавления
|
| 1638 |
products.sort(key=lambda x: x.get('name', '').lower())
|
| 1639 |
save_data(data)
|
| 1640 |
logging.info(f"Товар '{name}' добавлен.")
|
|
@@ -1655,7 +1520,6 @@ def admin():
|
|
| 1655 |
flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
|
| 1656 |
return redirect(url_for('admin'))
|
| 1657 |
|
| 1658 |
-
# Обновляем поля
|
| 1659 |
product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
|
| 1660 |
price_str = request.form.get('price', str(product_to_edit['price'])).replace(',', '.')
|
| 1661 |
product_to_edit['description'] = request.form.get('description', product_to_edit['description']).strip()
|
|
@@ -1671,7 +1535,6 @@ def admin():
|
|
| 1671 |
logging.warning(f"Неверный формат цены '{price_str}' при редактировании товара {original_name}. Цена не изменена.")
|
| 1672 |
flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning')
|
| 1673 |
|
| 1674 |
-
# Обработка новых фото (замена старых)
|
| 1675 |
photos_files = request.files.getlist('photos')
|
| 1676 |
if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
|
| 1677 |
uploads_dir = 'uploads_temp'
|
|
@@ -1703,18 +1566,14 @@ def admin():
|
|
| 1703 |
except Exception as e:
|
| 1704 |
logging.error(f"Ошибка загрузки нового фото {photo.filename}: {e}", exc_info=True)
|
| 1705 |
flash(f"Ошибка при загрузке нового фото {photo.filename}.", 'error')
|
| 1706 |
-
# Удаляем временную папку, если она пуста
|
| 1707 |
try:
|
| 1708 |
if not os.listdir(uploads_dir):
|
| 1709 |
os.rmdir(uploads_dir)
|
| 1710 |
except OSError as e:
|
| 1711 |
logging.warning(f"Не удалось удалить временную папку {uploads_dir}: {e}")
|
| 1712 |
|
| 1713 |
-
# Если были успешно загружены новые фото, заменяем старый список
|
| 1714 |
if new_photos_list:
|
| 1715 |
logging.info(f"Список фото для товара {product_to_edit['name']} обновлен.")
|
| 1716 |
-
# TODO: Удалить старые фото с HF? Это сложнее, требует хранения списка старых фото.
|
| 1717 |
-
# ----- Начало: Опциональное удаление старых фото ----
|
| 1718 |
old_photos = product_to_edit.get('photos', [])
|
| 1719 |
if old_photos:
|
| 1720 |
logging.info(f"Попытка удаления старых фото: {old_photos}")
|
|
@@ -1730,13 +1589,11 @@ def admin():
|
|
| 1730 |
except Exception as e:
|
| 1731 |
logging.error(f"Ошибка при удалении старых фото {old_photos} с HF: {e}", exc_info=True)
|
| 1732 |
flash("Не удалось удалить старые фотографии с сервера.", "warning")
|
| 1733 |
-
# ----- Конец: Опциональное удаление старых фото -----
|
| 1734 |
product_to_edit['photos'] = new_photos_list
|
| 1735 |
flash("Фотографии товара успешно обновлены.", "success")
|
| 1736 |
elif uploaded_count == 0 and any(f.filename for f in photos_files):
|
| 1737 |
flash("Не удалось загрузить новые фотографии.", "error")
|
| 1738 |
|
| 1739 |
-
# Сортируем продукты по имени после редактирования
|
| 1740 |
products.sort(key=lambda x: x.get('name', '').lower())
|
| 1741 |
save_data(data)
|
| 1742 |
logging.info(f"Товар '{original_name}' (индекс {index}) обновлен на '{product_to_edit['name']}'.")
|
|
@@ -1754,7 +1611,6 @@ def admin():
|
|
| 1754 |
deleted_product = products.pop(index)
|
| 1755 |
product_name = deleted_product.get('name', 'N/A')
|
| 1756 |
|
| 1757 |
-
# ----- Начало: Удаление фото с HF при удалении товара ----
|
| 1758 |
photos_to_delete = deleted_product.get('photos', [])
|
| 1759 |
if photos_to_delete and HF_TOKEN_WRITE:
|
| 1760 |
logging.info(f"Попытка удаления фото товара '{product_name}' с HF: {photos_to_delete}")
|
|
@@ -1771,7 +1627,6 @@ def admin():
|
|
| 1771 |
except Exception as e:
|
| 1772 |
logging.error(f"Ошибка при удалении фото {photos_to_delete} для товара '{product_name}' с HF: {e}", exc_info=True)
|
| 1773 |
flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning")
|
| 1774 |
-
# ----- Конец: Удаление фото с HF при удалении товара ----
|
| 1775 |
|
| 1776 |
save_data(data)
|
| 1777 |
logging.info(f"Товар '{product_name}' (индекс {index}) удален.")
|
|
@@ -1795,12 +1650,8 @@ def admin():
|
|
| 1795 |
flash(f"Пользователь с логином '{login}' уже существует.", 'error')
|
| 1796 |
return redirect(url_for('admin'))
|
| 1797 |
|
| 1798 |
-
# Важно: Хранить пароли в открытом виде НЕБЕЗОПАСНО!
|
| 1799 |
-
# Используйте хеширование для реальных приложений.
|
| 1800 |
-
# from passlib.hash import pbkdf2_sha256
|
| 1801 |
-
# password_hash = pbkdf2_sha256.hash(password)
|
| 1802 |
users[login] = {
|
| 1803 |
-
'password': password,
|
| 1804 |
'first_name': first_name, 'last_name': last_name,
|
| 1805 |
'country': country, 'city': city
|
| 1806 |
}
|
|
@@ -1823,36 +1674,28 @@ def admin():
|
|
| 1823 |
logging.warning(f"Получено неизвестное действие в админ-панели: {action}")
|
| 1824 |
flash(f"Неизвестное действие: {action}", 'warning')
|
| 1825 |
|
| 1826 |
-
# После обработки POST-запроса делаем редирект, чтобы избежать повторной отправки формы
|
| 1827 |
return redirect(url_for('admin'))
|
| 1828 |
|
| 1829 |
except Exception as e:
|
| 1830 |
logging.error(f"Ошибка при обработке действия '{action}' в админ-панели: {e}", exc_info=True)
|
| 1831 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1832 |
-
return redirect(url_for('admin'))
|
| 1833 |
|
| 1834 |
-
# GET запрос для отображения админ-панели
|
| 1835 |
-
# Сортируем продукты для отображения
|
| 1836 |
products.sort(key=lambda x: x.get('name', '').lower())
|
| 1837 |
-
# Сортируем категории
|
| 1838 |
categories.sort()
|
| 1839 |
-
# Сортируем пользователей по логину
|
| 1840 |
sorted_users = dict(sorted(users.items()))
|
| 1841 |
|
| 1842 |
return render_template_string(
|
| 1843 |
-
ADMIN_TEMPLATE,
|
| 1844 |
products=products,
|
| 1845 |
categories=categories,
|
| 1846 |
-
users=sorted_users,
|
| 1847 |
repo_id=REPO_ID,
|
| 1848 |
currency_code=CURRENCY_CODE
|
| 1849 |
)
|
| 1850 |
|
| 1851 |
-
# --- Маршруты для принудительной синхронизации ---
|
| 1852 |
-
|
| 1853 |
@app.route('/force_upload', methods=['POST'])
|
| 1854 |
def force_upload():
|
| 1855 |
-
# TODO: Добавить проверку прав администратора
|
| 1856 |
if not session.get('user'):
|
| 1857 |
flash("Требуется вход для выполнения этого действия.", 'warning')
|
| 1858 |
return redirect(url_for('login'))
|
|
@@ -1868,7 +1711,6 @@ def force_upload():
|
|
| 1868 |
|
| 1869 |
@app.route('/force_download', methods=['POST'])
|
| 1870 |
def force_download():
|
| 1871 |
-
# TODO: Добавить проверку прав администратора
|
| 1872 |
if not session.get('user'):
|
| 1873 |
flash("Требуется вход для выполнения этого действия.", 'warning')
|
| 1874 |
return redirect(url_for('login'))
|
|
@@ -1883,14 +1725,10 @@ def force_download():
|
|
| 1883 |
return redirect(url_for('admin'))
|
| 1884 |
|
| 1885 |
|
| 1886 |
-
# --- Запуск приложения ---
|
| 1887 |
-
|
| 1888 |
if __name__ == '__main__':
|
| 1889 |
-
# Попытка загрузить/создать файлы данных при старте
|
| 1890 |
load_data()
|
| 1891 |
load_users()
|
| 1892 |
|
| 1893 |
-
# Запуск потока для периодического бэкапа
|
| 1894 |
if HF_TOKEN_WRITE:
|
| 1895 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
| 1896 |
backup_thread.start()
|
|
@@ -1898,12 +1736,6 @@ if __name__ == '__main__':
|
|
| 1898 |
else:
|
| 1899 |
logging.warning("Периодическое резервное копирование НЕ будет запущено (HF_TOKEN или HF_TOKEN_WRITE не установлена).")
|
| 1900 |
|
| 1901 |
-
|
| 1902 |
-
port = int(os.environ.get('PORT', 7860)) # Порт по умолчанию 7860, можно изменить через переменную окружения PORT
|
| 1903 |
logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
|
| 1904 |
-
# debug=False для продакшена! Установите в True только для локальной разработки.
|
| 1905 |
-
# Использование app.run() подходит только для разработки.
|
| 1906 |
-
# Для продакшена используйте WSGI сервер, например, Gunicorn или Waitress.
|
| 1907 |
-
# Пример с Gunicorn: gunicorn --bind 0.0.0.0:7860 app:app
|
| 1908 |
-
# Пример с Waitress: waitress-serve --host 0.0.0.0 --port 7860 app:app
|
| 1909 |
app.run(debug=False, host='0.0.0.0', port=port)
|
|
|
|
| 1 |
|
|
|
|
| 2 |
from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash
|
| 3 |
import json
|
| 4 |
import os
|
|
|
|
| 9 |
from huggingface_hub import HfApi, hf_hub_download
|
| 10 |
from huggingface_hub.utils import RepositoryNotFoundError
|
| 11 |
from werkzeug.utils import secure_filename
|
|
|
|
| 12 |
from dotenv import load_dotenv
|
| 13 |
|
|
|
|
| 14 |
load_dotenv()
|
| 15 |
|
| 16 |
app = Flask(__name__)
|
| 17 |
+
app.secret_key = 'your_unique_secret_key_soola_cosmetics_67890'
|
| 18 |
DATA_FILE = 'data_soola.json'
|
| 19 |
USERS_FILE = 'users_soola.json'
|
| 20 |
|
|
|
|
| 21 |
SYNC_FILES = [DATA_FILE, USERS_FILE]
|
| 22 |
|
| 23 |
+
REPO_ID = "Kgshop/Soola"
|
|
|
|
|
|
|
| 24 |
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
|
| 25 |
+
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
| 26 |
|
| 27 |
+
STORE_ADDRESS = "Рынок Дордой, Джунхай, терминал, 38"
|
|
|
|
| 28 |
|
|
|
|
| 29 |
CURRENCY_CODE = 'KGS'
|
| 30 |
CURRENCY_NAME = 'Кыргызский сом (с)'
|
| 31 |
|
|
|
|
|
|
|
| 32 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 33 |
|
|
|
|
|
|
|
| 34 |
def load_data():
|
|
|
|
| 35 |
try:
|
|
|
|
| 36 |
download_db_from_hf()
|
| 37 |
with open(DATA_FILE, 'r', encoding='utf-8') as file:
|
| 38 |
data = json.load(file)
|
| 39 |
logging.info(f"Данные успешно загружены из {DATA_FILE}")
|
|
|
|
| 40 |
if not isinstance(data, dict):
|
| 41 |
logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.")
|
| 42 |
return {'products': [], 'categories': []}
|
|
|
|
| 48 |
except FileNotFoundError:
|
| 49 |
logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачать с HF.")
|
| 50 |
try:
|
|
|
|
|
|
|
| 51 |
if not os.path.exists(DATA_FILE):
|
| 52 |
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f)
|
| 53 |
logging.info(f"Создан пустой файл {DATA_FILE}")
|
| 54 |
return {'products': [], 'categories': []}
|
| 55 |
+
else:
|
| 56 |
with open(DATA_FILE, 'r', encoding='utf-8') as file:
|
| 57 |
data = json.load(file)
|
| 58 |
logging.info(f"Данные успешно загружены из {DATA_FILE} после попытки скачивания.")
|
|
|
|
| 60 |
if 'products' not in data: data['products'] = []
|
| 61 |
if 'categories' not in data: data['categories'] = []
|
| 62 |
return data
|
| 63 |
+
except (FileNotFoundError, RepositoryNotFoundError) as e:
|
| 64 |
logging.warning(f"Файл {DATA_FILE} не найден локально и ошибка при доступе к репозиторию HF ({e}). Создание пустой структуры.")
|
| 65 |
if not os.path.exists(DATA_FILE):
|
| 66 |
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f)
|
|
|
|
| 73 |
return {'products': [], 'categories': []}
|
| 74 |
except json.JSONDecodeError:
|
| 75 |
logging.error(f"Ошибка декодирования JSON в локальном {DATA_FILE}. Файл может быть поврежден. Возврат пустой структуры.")
|
|
|
|
| 76 |
return {'products': [], 'categories': []}
|
| 77 |
except Exception as e:
|
| 78 |
logging.error(f"Неизвестная ошибка при загрузке данных ({DATA_FILE}): {e}", exc_info=True)
|
|
|
|
| 80 |
|
| 81 |
|
| 82 |
def save_data(data):
|
|
|
|
| 83 |
try:
|
| 84 |
with open(DATA_FILE, 'w', encoding='utf-8') as file:
|
| 85 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 86 |
logging.info(f"Данные успешно сохранены в {DATA_FILE}")
|
|
|
|
| 87 |
upload_db_to_hf(specific_file=DATA_FILE)
|
| 88 |
except Exception as e:
|
| 89 |
logging.error(f"Ошибка при сохранении данных в {DATA_FILE}: {e}", exc_info=True)
|
|
|
|
|
|
|
| 90 |
|
| 91 |
def load_users():
|
|
|
|
| 92 |
try:
|
|
|
|
|
|
|
| 93 |
with open(USERS_FILE, 'r', encoding='utf-8') as file:
|
| 94 |
users = json.load(file)
|
| 95 |
logging.info(f"Данные пользователей успешно загружены из {USERS_FILE}")
|
|
|
|
| 97 |
except FileNotFoundError:
|
| 98 |
logging.warning(f"Локальный файл {USERS_FILE} не найден. Попытка скачать с HF.")
|
| 99 |
try:
|
| 100 |
+
download_db_from_hf(specific_file=USERS_FILE)
|
|
|
|
| 101 |
with open(USERS_FILE, 'r', encoding='utf-8') as file:
|
| 102 |
users = json.load(file)
|
| 103 |
logging.info(f"Данные пользователей загружены из {USERS_FILE} после скачивания.")
|
| 104 |
return users if isinstance(users, dict) else {}
|
| 105 |
except (FileNotFoundError, RepositoryNotFoundError):
|
| 106 |
logging.warning(f"Файл {USERS_FILE} не найден локально и в репозитории HF. Создание пустого файла.")
|
|
|
|
| 107 |
with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f)
|
| 108 |
return {}
|
| 109 |
except json.JSONDecodeError:
|
|
|
|
| 120 |
return {}
|
| 121 |
|
| 122 |
def save_users(users):
|
|
|
|
| 123 |
try:
|
| 124 |
with open(USERS_FILE, 'w', encoding='utf-8') as file:
|
| 125 |
json.dump(users, file, ensure_ascii=False, indent=4)
|
| 126 |
logging.info(f"Данные пользователей успешно сохранены в {USERS_FILE}")
|
|
|
|
| 127 |
upload_db_to_hf(specific_file=USERS_FILE)
|
| 128 |
except Exception as e:
|
| 129 |
logging.error(f"Ошибка при сохранении данных пользователей в {USERS_FILE}: {e}", exc_info=True)
|
| 130 |
|
|
|
|
| 131 |
|
| 132 |
def upload_db_to_hf(specific_file=None):
|
|
|
|
|
|
|
|
|
|
| 133 |
if not HF_TOKEN_WRITE:
|
| 134 |
logging.warning("Переменная окружения HF_TOKEN (для записи) не установлена. Загрузка на Hugging Face пропущена.")
|
| 135 |
return
|
|
|
|
| 152 |
logging.info(f"Файл {file_name} успешно загружен на Hugging Face.")
|
| 153 |
except Exception as e:
|
| 154 |
logging.error(f"Ошибка при загрузке файла {file_name} на Hugging Face: {e}")
|
|
|
|
| 155 |
else:
|
| 156 |
logging.warning(f"Файл {file_name} не найден локально, пропуск загрузки.")
|
| 157 |
logging.info("Загрузка файлов на HF завершена.")
|
|
|
|
| 159 |
logging.error(f"Общая ошибка при инициализации или загрузке на Hugging Face: {e}", exc_info=True)
|
| 160 |
|
| 161 |
def download_db_from_hf(specific_file=None):
|
|
|
|
|
|
|
|
|
|
| 162 |
if not HF_TOKEN_READ:
|
|
|
|
| 163 |
logging.warning("Переменная окружения HF_TOKEN_READ не установлена. Попытка скачивания с Hugging Face без токена (может не сработать для приватных репо).")
|
|
|
|
| 164 |
|
| 165 |
files_to_download = [specific_file] if specific_file else SYNC_FILES
|
| 166 |
logging.info(f"Начало скачивания файлов {files_to_download} с HF репозитория {REPO_ID}...")
|
| 167 |
downloaded_files_count = 0
|
| 168 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
for file_name in files_to_download:
|
| 170 |
try:
|
|
|
|
| 171 |
local_path = hf_hub_download(
|
| 172 |
repo_id=REPO_ID,
|
| 173 |
filename=file_name,
|
| 174 |
repo_type="dataset",
|
| 175 |
+
token=HF_TOKEN_READ,
|
| 176 |
local_dir=".",
|
| 177 |
local_dir_use_symlinks=False,
|
| 178 |
+
force_download=True
|
| 179 |
)
|
| 180 |
logging.info(f"Файл {file_name} успешно скачан из Hugging Face в {local_path}.")
|
| 181 |
downloaded_files_count += 1
|
| 182 |
except RepositoryNotFoundError:
|
| 183 |
logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.")
|
| 184 |
+
break
|
| 185 |
+
except Exception as e:
|
|
|
|
|
|
|
| 186 |
if "404" in str(e) or isinstance(e, FileNotFoundError):
|
| 187 |
logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск скачивания этого файла.")
|
| 188 |
else:
|
|
|
|
| 189 |
logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True)
|
| 190 |
logging.info(f"Скачивание файлов с HF завершено. Скачано файлов: {downloaded_files_count}/{len(files_to_download)}.")
|
| 191 |
except RepositoryNotFoundError:
|
|
|
|
| 192 |
logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание не выполнено.")
|
| 193 |
except Exception as e:
|
|
|
|
| 194 |
logging.error(f"Общая ошибка при попытке скачивания с Hugging Face: {e}", exc_info=True)
|
|
|
|
| 195 |
|
| 196 |
|
| 197 |
def periodic_backup():
|
| 198 |
+
backup_interval = 1800
|
|
|
|
| 199 |
logging.info(f"Настройка периодического резервного копирования каждые {backup_interval} секунд.")
|
| 200 |
while True:
|
| 201 |
time.sleep(backup_interval)
|
| 202 |
logging.info("Запуск периодического резервного копирования...")
|
| 203 |
+
upload_db_to_hf()
|
| 204 |
logging.info("Периодическое резервное копирование завершено.")
|
| 205 |
|
| 206 |
|
|
|
|
|
|
|
| 207 |
@app.route('/')
|
| 208 |
def catalog():
|
|
|
|
| 209 |
data = load_data()
|
| 210 |
products = data.get('products', [])
|
| 211 |
categories = data.get('categories', [])
|
| 212 |
is_authenticated = 'user' in session
|
| 213 |
|
|
|
|
| 214 |
catalog_html = '''
|
| 215 |
<!DOCTYPE html>
|
| 216 |
<html lang="ru">
|
|
|
|
| 222 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 223 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
|
| 224 |
<style>
|
|
|
|
| 225 |
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 226 |
body { font-family: 'Poppins', sans-serif; background: #f0f9f4; color: #2d332f; line-height: 1.6; transition: background 0.3s, color 0.3s; }
|
| 227 |
body.dark-mode { background: #1a2b26; color: #c8d8d3; }
|
| 228 |
.container { max-width: 1300px; margin: 0 auto; padding: 20px; }
|
| 229 |
.header { display: flex; justify-content: space-between; align-items: center; padding: 15px 0; border-bottom: 1px solid #d1e7dd; }
|
| 230 |
body.dark-mode .header { border-bottom-color: #2c4a41; }
|
| 231 |
+
.header h1 { font-size: 1.8rem; font-weight: 600; color: #1C6758; }
|
| 232 |
.auth-links { display: flex; gap: 15px; align-items: center; }
|
| 233 |
+
.auth-links a { color: #3D8361; text-decoration: none; font-weight: 500; }
|
| 234 |
.auth-links a:hover { text-decoration: underline; }
|
| 235 |
+
body.dark-mode .auth-links a { color: #55a683; }
|
| 236 |
.auth-links span { font-weight: 500; }
|
| 237 |
body.dark-mode .auth-links span { color: #b0c8c1;}
|
| 238 |
.theme-toggle { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #7a8d85; transition: color 0.3s ease; }
|
|
|
|
| 242 |
.store-address { padding: 15px; text-align: center; background-color: #ffffff; margin: 20px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); font-size: 1rem; color: #44524c; }
|
| 243 |
body.dark-mode .store-address { background-color: #253f37; color: #b0c8c1; }
|
| 244 |
|
|
|
|
| 245 |
.filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
| 246 |
.search-container { margin: 20px 0; text-align: center; }
|
| 247 |
#search-input { width: 90%; max-width: 600px; padding: 12px 18px; font-size: 1rem; border: 1px solid #d1e7dd; border-radius: 25px; outline: none; box-shadow: 0 2px 5px rgba(0,0,0,0.05); transition: all 0.3s ease; }
|
| 248 |
body.dark-mode #search-input { background-color: #253f37; border-color: #2c4a41; color: #c8d8d3; }
|
| 249 |
+
#search-input:focus { border-color: #1C6758; box-shadow: 0 0 0 3px rgba(28, 103, 88, 0.2); }
|
| 250 |
+
body.dark-mode #search-input:focus { border-color: #3D8361; box-shadow: 0 0 0 3px rgba(61, 131, 97, 0.3); }
|
| 251 |
+
.category-filter { padding: 8px 16px; border: 1px solid #d1e7dd; 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; color: #1C6758; }
|
| 252 |
body.dark-mode .category-filter { background-color: #253f37; border-color: #2c4a41; color: #97b7ae; }
|
| 253 |
+
.category-filter.active, .category-filter:hover { background-color: #1C6758; color: white; border-color: #1C6758; box-shadow: 0 2px 10px rgba(28, 103, 88, 0.3); }
|
| 254 |
+
body.dark-mode .category-filter.active, body.dark-mode .category-filter:hover { background-color: #3D8361; border-color: #3D8361; color: #1a2b26; box-shadow: 0 2px 10px rgba(61, 131, 97, 0.4); }
|
| 255 |
|
|
|
|
| 256 |
.products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; padding: 10px; }
|
| 257 |
@media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } }
|
| 258 |
.product { background: #fff; border-radius: 15px; padding: 0; 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; justify-content: space-between; height: 100%; border: 1px solid #e1f0e9;}
|
|
|
|
| 265 |
.product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
|
| 266 |
.product h2 { font-size: 1.1rem; font-weight: 600; margin: 0 0 8px 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #2d332f; }
|
| 267 |
body.dark-mode .product h2 { color: #c8d8d3; }
|
| 268 |
+
.product-price { font-size: 1.2rem; color: #1C6758; font-weight: 700; text-align: center; margin: 5px 0; }
|
| 269 |
+
body.dark-mode .product-price { color: #55a683; }
|
| 270 |
.product-description { font-size: 0.85rem; color: #7a8d85; text-align: center; margin-bottom: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 271 |
body.dark-mode .product-description { color: #8aa39a; }
|
| 272 |
.product-actions { padding: 0 15px 15px 15px; display: flex; flex-direction: column; gap: 8px; }
|
| 273 |
+
.product-button { display: block; width: 100%; padding: 10px; border: none; border-radius: 8px; background-color: #1C6758; color: white; font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); text-align: center; text-decoration: none; }
|
| 274 |
+
.product-button:hover { background-color: #164B41; box-shadow: 0 4px 15px rgba(22, 75, 65, 0.4); transform: translateY(-2px); }
|
| 275 |
.product-button i { margin-right: 5px; }
|
| 276 |
|
| 277 |
+
.add-to-cart { background-color: #38a169; }
|
|
|
|
| 278 |
.add-to-cart:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
|
| 279 |
+
#cart-button { position: fixed; bottom: 25px; right: 25px; background-color: #1C6758; 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(28, 103, 88, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; }
|
| 280 |
#cart-button .fa-shopping-cart { margin-right: 0; }
|
| 281 |
+
#cart-button span { position: absolute; top: -5px; right: -5px; background-color: #38a169; color: white; border-radius: 50%; padding: 2px 6px; font-size: 0.7rem; font-weight: bold; }
|
| 282 |
|
|
|
|
| 283 |
.modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.6); backdrop-filter: blur(5px); overflow-y: auto; }
|
| 284 |
.modal-content { background: #f8fcfb; 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; position: relative; }
|
| 285 |
body.dark-mode .modal-content { background: #253f37; color: #c8d8d3; }
|
|
|
|
| 288 |
.close:hover { color: #333; }
|
| 289 |
body.dark-mode .close { color: #7a8d85; }
|
| 290 |
body.dark-mode .close:hover { color: #b0c8c1; }
|
| 291 |
+
.modal-content h2 { margin-top: 0; margin-bottom: 20px; color: #1C6758; display: flex; align-items: center; gap: 10px;}
|
| 292 |
+
body.dark-mode .modal-content h2 { color: #55a683; }
|
| 293 |
.cart-item { display: grid; grid-template-columns: auto 1fr auto auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #d1e7dd; }
|
| 294 |
body.dark-mode .cart-item { border-bottom-color: #2c4a41; }
|
| 295 |
.cart-item:last-child { border-bottom: none; }
|
|
|
|
| 299 |
.cart-item-price { font-size: 0.9rem; color: #44524c; }
|
| 300 |
body.dark-mode .cart-item-price { color: #8aa39a; }
|
| 301 |
.cart-item-total { font-weight: bold; text-align: right; grid-column: 3; }
|
| 302 |
+
.cart-item-remove { grid-column: 4; background:none; border:none; color:#f56565; cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; }
|
| 303 |
.cart-item-remove:hover { color: #c53030; }
|
| 304 |
.quantity-input, .color-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #d1e7dd; border-radius: 8px; font-size: 1rem; margin: 10px 0; box-sizing: border-box; }
|
| 305 |
body.dark-mode .quantity-input, body.dark-mode .color-select { background-color: #1a2b26; border-color: #2c4a41; color: #c8d8d3; }
|
|
|
|
| 307 |
body.dark-mode .cart-summary { border-top-color: #2c4a41; }
|
| 308 |
.cart-summary strong { font-size: 1.2rem; }
|
| 309 |
.cart-actions { margin-top: 25px; display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
|
| 310 |
+
.cart-actions .product-button { width: auto; flex-grow: 1; }
|
| 311 |
+
.clear-cart { background-color: #7a8d85; }
|
| 312 |
.clear-cart:hover { background-color: #5e6e68; box-shadow: 0 4px 15px rgba(94, 110, 104, 0.4); }
|
| 313 |
+
.order-button { background-color: #38a169; }
|
| 314 |
.order-button:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
|
| 315 |
|
| 316 |
+
.notification { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); background-color: #38a169; color: white; padding: 10px 20px; border-radius: 20px; box-shadow: 0 4px 10px rgba(0,0,0,0.2); z-index: 1002; opacity: 0; transition: opacity 0.5s ease; font-size: 0.9rem;}
|
|
|
|
| 317 |
.notification.show { opacity: 1;}
|
| 318 |
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #5e6e68; }
|
| 319 |
body.dark-mode .no-results-message { color: #8aa39a; }
|
|
|
|
| 383 |
</div>
|
| 384 |
</div>
|
| 385 |
{% endfor %}
|
|
|
|
| 386 |
{% if not products %}
|
| 387 |
<p class="no-results-message">Товары пока не добавл��ны.</p>
|
| 388 |
{% endif %}
|
| 389 |
</div>
|
| 390 |
</div>
|
| 391 |
|
|
|
|
| 392 |
<div id="productModal" class="modal">
|
| 393 |
<div class="modal-content">
|
| 394 |
<span class="close" onclick="closeModal('productModal')" aria-label="Закрыть">×</span>
|
|
|
|
| 396 |
</div>
|
| 397 |
</div>
|
| 398 |
|
|
|
|
| 399 |
<div id="quantityModal" class="modal">
|
| 400 |
<div class="modal-content">
|
| 401 |
<span class="close" onclick="closeModal('quantityModal')" aria-label="Закрыть">×</span>
|
|
|
|
| 408 |
</div>
|
| 409 |
</div>
|
| 410 |
|
|
|
|
| 411 |
<div id="cartModal" class="modal">
|
| 412 |
<div class="modal-content">
|
| 413 |
<span class="close" onclick="closeModal('cartModal')" aria-label="Закрыть">×</span>
|
|
|
|
| 427 |
</div>
|
| 428 |
</div>
|
| 429 |
|
|
|
|
| 430 |
<button id="cart-button" onclick="openCartModal()" aria-label="Открыть корзину">
|
| 431 |
<i class="fas fa-shopping-cart"></i>
|
| 432 |
<span id="cart-count">0</span>
|
| 433 |
</button>
|
| 434 |
|
|
|
|
| 435 |
<div id="notification-placeholder"></div>
|
| 436 |
|
| 437 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
|
| 438 |
<script>
|
|
|
|
| 439 |
const products = {{ products|tojson }};
|
| 440 |
const repoId = '{{ repo_id }}';
|
| 441 |
const currencyCode = '{{ currency_code }}';
|
|
|
|
| 443 |
let selectedProductIndex = null;
|
| 444 |
let cart = JSON.parse(localStorage.getItem('soolaCart') || '[]');
|
| 445 |
|
|
|
|
| 446 |
function toggleTheme() {
|
| 447 |
document.body.classList.toggle('dark-mode');
|
| 448 |
const icon = document.querySelector('.theme-toggle i');
|
|
|
|
| 461 |
}
|
| 462 |
}
|
| 463 |
|
|
|
|
| 464 |
function attemptAutoLogin() {
|
| 465 |
const storedUser = localStorage.getItem('soolaUser');
|
| 466 |
if (storedUser && !isAuthenticated) {
|
|
|
|
| 486 |
}
|
| 487 |
}
|
| 488 |
|
|
|
|
| 489 |
function openModal(index) {
|
| 490 |
loadProductDetails(index);
|
| 491 |
const modal = document.getElementById('productModal');
|
|
|
|
| 536 |
pagination: { el: '.swiper-pagination', clickable: true },
|
| 537 |
navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
|
| 538 |
zoom: { maxRatio: 3, containerClass: 'swiper-zoom-container' },
|
| 539 |
+
autoplay: { delay: 5000, disableOnInteraction: true, },
|
| 540 |
});
|
| 541 |
}
|
| 542 |
}
|
| 543 |
|
|
|
|
| 544 |
function openQuantityModal(index) {
|
| 545 |
if (!isAuthenticated) {
|
| 546 |
alert('Пожалуйста, войдите в систему, чтобы добавить товар в корзину.');
|
|
|
|
| 557 |
|
| 558 |
const colorSelect = document.getElementById('colorSelect');
|
| 559 |
const colorLabel = document.querySelector('label[for="colorSelect"]');
|
| 560 |
+
colorSelect.innerHTML = '';
|
| 561 |
|
| 562 |
const validColors = product.colors ? product.colors.filter(c => c && c.trim() !== "") : [];
|
| 563 |
|
|
|
|
| 685 |
function removeFromCart(itemId) {
|
| 686 |
cart = cart.filter(item => item.id !== itemId);
|
| 687 |
localStorage.setItem('soolaCart', JSON.stringify(cart));
|
| 688 |
+
openCartModal();
|
| 689 |
+
updateCartButton();
|
| 690 |
}
|
| 691 |
|
| 692 |
function clearCart() {
|
| 693 |
if (confirm("Вы уверены, что хотите очистить корзину?")) {
|
| 694 |
cart = [];
|
| 695 |
localStorage.removeItem('soolaCart');
|
| 696 |
+
openCartModal();
|
| 697 |
updateCartButton();
|
|
|
|
| 698 |
}
|
| 699 |
}
|
| 700 |
|
|
|
|
| 704 |
return;
|
| 705 |
}
|
| 706 |
let total = 0;
|
| 707 |
+
let orderText = "Новый Заказ от Soola Cosmetics:%0A%0A";
|
| 708 |
cart.forEach((item, index) => {
|
| 709 |
const itemTotal = item.price * item.quantity;
|
| 710 |
total += itemTotal;
|
| 711 |
const colorText = item.color !== 'N/A' ? ` (Цвет: ${item.color})` : '';
|
|
|
|
| 712 |
orderText += `${index + 1}. ${item.name}${colorText} - ${item.price.toFixed(2)} ${currencyCode} × ${item.quantity} = ${itemTotal.toFixed(2)} ${currencyCode}%0A`;
|
| 713 |
});
|
| 714 |
+
orderText += `%0A*Итого: ${total.toFixed(2)} ${currencyCode}*%0A%0A`;
|
| 715 |
|
| 716 |
+
// --- FIX STARTS HERE ---
|
| 717 |
const userInfo = {{ session.get('user_info', {})|tojson }};
|
| 718 |
if (userInfo && userInfo.login) {
|
| 719 |
+
// Use direct property access or || for defaults
|
| 720 |
+
orderText += `Заказчик: ${userInfo.first_name || ''} ${userInfo.last_name || ''}%0A`;
|
| 721 |
orderText += `Логин: ${userInfo.login}%0A`;
|
| 722 |
+
orderText += `Страна: ${userInfo.country || 'Не указана'}%0A`;
|
| 723 |
+
orderText += `Город: ${userInfo.city || 'Не указан'}%0A`;
|
| 724 |
} else {
|
| 725 |
orderText += `Заказчик: (Не авторизован)%0A`;
|
| 726 |
}
|
| 727 |
+
// --- FIX ENDS HERE ---
|
| 728 |
|
|
|
|
| 729 |
const now = new Date();
|
| 730 |
+
const dateTimeString = now.toLocaleString('ru-RU');
|
| 731 |
orderText += `%0AДата заказа: ${dateTimeString}`;
|
| 732 |
|
| 733 |
+
const whatsappNumber = "996997703090";
|
|
|
|
|
|
|
| 734 |
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
|
|
|
|
| 735 |
window.open(whatsappUrl, '_blank');
|
| 736 |
}
|
| 737 |
|
|
|
|
| 738 |
function filterProducts() {
|
| 739 |
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
|
| 740 |
const activeCategoryButton = document.querySelector('.category-filter.active');
|
|
|
|
| 742 |
const grid = document.getElementById('products-grid');
|
| 743 |
let visibleProducts = 0;
|
| 744 |
|
|
|
|
| 745 |
const existingNoResults = grid.querySelector('.no-results-message');
|
| 746 |
if (existingNoResults) existingNoResults.remove();
|
| 747 |
|
|
|
|
| 761 |
}
|
| 762 |
});
|
| 763 |
|
|
|
|
| 764 |
if (visibleProducts === 0 && (searchTerm || activeCategory !== 'all')) {
|
| 765 |
const p = document.createElement('p');
|
| 766 |
p.className = 'no-results-message';
|
|
|
|
| 784 |
});
|
| 785 |
}
|
| 786 |
|
|
|
|
| 787 |
function showNotification(message, duration = 3000) {
|
| 788 |
const placeholder = document.getElementById('notification-placeholder');
|
| 789 |
if (!placeholder) return;
|
|
|
|
| 793 |
notification.textContent = message;
|
| 794 |
placeholder.appendChild(notification);
|
| 795 |
|
|
|
|
| 796 |
setTimeout(() => { notification.classList.add('show'); }, 10);
|
| 797 |
|
|
|
|
| 798 |
setTimeout(() => {
|
| 799 |
notification.classList.remove('show');
|
| 800 |
+
setTimeout(() => { notification.remove(); }, 500);
|
| 801 |
}, duration);
|
| 802 |
}
|
| 803 |
|
|
|
|
| 804 |
document.addEventListener('DOMContentLoaded', () => {
|
| 805 |
applyInitialTheme();
|
| 806 |
+
attemptAutoLogin();
|
| 807 |
+
updateCartButton();
|
| 808 |
+
setupFilters();
|
| 809 |
|
|
|
|
| 810 |
window.addEventListener('click', function(event) {
|
| 811 |
if (event.target.classList.contains('modal')) {
|
| 812 |
closeModal(event.target.id);
|
| 813 |
}
|
| 814 |
});
|
| 815 |
|
|
|
|
| 816 |
window.addEventListener('keydown', function(event) {
|
| 817 |
if (event.key === 'Escape') {
|
| 818 |
document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
|
|
|
|
| 833 |
repo_id=REPO_ID,
|
| 834 |
is_authenticated=is_authenticated,
|
| 835 |
store_address=STORE_ADDRESS,
|
| 836 |
+
session=session,
|
| 837 |
currency_code=CURRENCY_CODE
|
| 838 |
)
|
| 839 |
|
| 840 |
|
| 841 |
@app.route('/product/<int:index>')
|
| 842 |
def product_detail(index):
|
|
|
|
| 843 |
data = load_data()
|
| 844 |
products = data.get('products', [])
|
| 845 |
is_authenticated = 'user' in session
|
|
|
|
| 849 |
logging.warning(f"Попытка доступа к несуществующему продукту с индексом {index}")
|
| 850 |
return "Товар не найден", 404
|
| 851 |
|
|
|
|
| 852 |
detail_html = '''
|
|
|
|
| 853 |
<div style="padding: 10px;">
|
| 854 |
+
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #1C6758;">{{ product['name'] }}</h2>
|
|
|
|
| 855 |
<div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
|
| 856 |
<div class="swiper-wrapper">
|
| 857 |
{% if product.get('photos') and product['photos']|length > 0 %}
|
| 858 |
{% for photo in product['photos'] %}
|
| 859 |
<div class="swiper-slide" style="display: flex; justify-content: center; align-items: center; padding: 10px;">
|
| 860 |
+
<div class="swiper-zoom-container">
|
| 861 |
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}"
|
| 862 |
alt="{{ product['name'] }} - фото {{ loop.index }}"
|
| 863 |
style="max-width: 100%; max-height: 400px; object-fit: contain; display: block; margin: auto; cursor: grab;">
|
|
|
|
| 870 |
</div>
|
| 871 |
{% endif %}
|
| 872 |
</div>
|
|
|
|
| 873 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 874 |
<div class="swiper-pagination" style="position: relative; bottom: 5px;"></div>
|
| 875 |
+
<div class="swiper-button-next" style="color: #1C6758;"></div>
|
| 876 |
+
<div class="swiper-button-prev" style="color: #1C6758;"></div>
|
| 877 |
{% endif %}
|
| 878 |
</div>
|
| 879 |
|
|
|
|
| 880 |
<div style="margin-top: 20px; font-size: 1rem; line-height: 1.7;">
|
| 881 |
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
|
| 882 |
{% if is_authenticated %}
|
| 883 |
+
<p style="font-size: 1.2rem; font-weight: bold; color: #1C6758;"><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 884 |
{% else %}
|
| 885 |
+
<p><strong>Цена:</strong> <a href="{{ url_for('login') }}" style="color: #3D8361; text-decoration: underline;">Доступна после входа</a></p>
|
| 886 |
{% endif %}
|
| 887 |
+
<p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\\n', '<br>')|safe }}</p>
|
|
|
|
| 888 |
{% set colors = product.get('colors', []) %}
|
| 889 |
+
{% if colors and colors|select('ne', '')|list|length > 0 %}
|
| 890 |
<p><strong>Доступные цвета/варианты:</strong> {{ colors|select('ne', '')|join(', ') }}</p>
|
| 891 |
{% endif %}
|
| 892 |
</div>
|
|
|
|
| 900 |
currency_code=CURRENCY_CODE
|
| 901 |
)
|
| 902 |
|
|
|
|
|
|
|
|
|
|
| 903 |
LOGIN_TEMPLATE = '''
|
| 904 |
<!DOCTYPE html>
|
| 905 |
<html lang="ru">
|
|
|
|
| 909 |
<title>Вход - Soola Cosmetics</title>
|
| 910 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 911 |
<style>
|
| 912 |
+
body { font-family: 'Poppins', sans-serif; background: linear-gradient(135deg, #d1e7dd, #e9f5f0); display: flex; justify-content: center; align-items: center; min-height: 100vh; padding: 20px; }
|
| 913 |
.container { max-width: 400px; width: 100%; background: #fff; padding: 30px 40px; border-radius: 15px; box-shadow: 0 5px 20px rgba(0,0,0,0.1); text-align: center; }
|
| 914 |
+
h2 { color: #1C6758; margin-bottom: 25px; font-weight: 600; }
|
| 915 |
label { display: block; text-align: left; margin: 15px 0 5px; font-weight: 500; color: #44524c; }
|
| 916 |
input[type="text"], input[type="password"] { width: 100%; padding: 12px; margin-bottom: 15px; border: 1px solid #c4d9d1; border-radius: 8px; box-sizing: border-box; font-size: 1rem; }
|
| 917 |
+
input:focus { border-color: #1C6758; outline: none; box-shadow: 0 0 0 2px rgba(28, 103, 88, 0.2); }
|
| 918 |
+
button { width: 100%; padding: 12px; background-color: #1C6758; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1rem; font-weight: 600; transition: background-color 0.3s ease; margin-top: 10px; }
|
| 919 |
+
button:hover { background-color: #164B41; }
|
| 920 |
+
.error { color: #721c24; background-color: #f8d7da; border: 1px solid #f5c6cb; padding: 10px; border-radius: 8px; margin-bottom: 15px; font-size: 0.9rem; text-align: left;}
|
| 921 |
+
.back-link { display: inline-block; margin-top: 20px; color: #3D8361; text-decoration: none; font-size: 0.9rem; }
|
| 922 |
.back-link:hover { text-decoration: underline; }
|
| 923 |
</style>
|
| 924 |
</head>
|
|
|
|
| 943 |
|
| 944 |
@app.route('/login', methods=['GET', 'POST'])
|
| 945 |
def login():
|
|
|
|
| 946 |
if request.method == 'POST':
|
| 947 |
login = request.form.get('login')
|
| 948 |
password = request.form.get('password')
|
|
|
|
| 951 |
|
| 952 |
users = load_users()
|
| 953 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 954 |
if login in users and users[login].get('password') == password:
|
| 955 |
user_info = users[login]
|
| 956 |
session['user'] = login
|
|
|
|
| 977 |
error_message = "Неверный логин или пароль."
|
| 978 |
return render_template_string(LOGIN_TEMPLATE, error=error_message), 401
|
| 979 |
|
|
|
|
| 980 |
return render_template_string(LOGIN_TEMPLATE, error=None)
|
| 981 |
|
| 982 |
|
| 983 |
@app.route('/auto_login', methods=['POST'])
|
| 984 |
def auto_login():
|
|
|
|
| 985 |
data = request.get_json()
|
| 986 |
if not data or 'login' not in data:
|
| 987 |
logging.warning("Запрос auto_login без данных или логина.")
|
|
|
|
| 994 |
|
| 995 |
users = load_users()
|
| 996 |
if login in users:
|
|
|
|
|
|
|
| 997 |
user_info = users[login]
|
| 998 |
session['user'] = login
|
| 999 |
session['user_info'] = {
|
|
|
|
| 1007 |
return "OK", 200
|
| 1008 |
else:
|
| 1009 |
logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}.")
|
| 1010 |
+
return "Ошибка авто-входа", 400
|
|
|
|
|
|
|
| 1011 |
|
| 1012 |
@app.route('/logout')
|
| 1013 |
def logout():
|
|
|
|
| 1014 |
logged_out_user = session.get('user')
|
| 1015 |
session.pop('user', None)
|
| 1016 |
session.pop('user_info', None)
|
| 1017 |
if logged_out_user:
|
| 1018 |
logging.info(f"Пользователь {logged_out_user} вышел из системы.")
|
|
|
|
| 1019 |
logout_response_html = '''
|
| 1020 |
<!DOCTYPE html><html><head><title>Выход...</title></head><body>
|
| 1021 |
<script>
|
|
|
|
| 1027 |
'''
|
| 1028 |
return logout_response_html
|
| 1029 |
|
|
|
|
|
|
|
| 1030 |
ADMIN_TEMPLATE = '''
|
| 1031 |
<!DOCTYPE html>
|
| 1032 |
<html lang="ru">
|
|
|
|
| 1037 |
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 1038 |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 1039 |
<style>
|
| 1040 |
+
body { font-family: 'Poppins', sans-serif; background-color: #e9f5f0; color: #2d332f; padding: 20px; line-height: 1.6; }
|
| 1041 |
.container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 25px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }
|
| 1042 |
.header { padding-bottom: 15px; margin-bottom: 25px; border-bottom: 1px solid #d1e7dd; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
|
| 1043 |
+
h1, h2, h3 { font-weight: 600; color: #1C6758; margin-bottom: 15px; }
|
| 1044 |
h1 { font-size: 1.8rem; }
|
| 1045 |
h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; }
|
| 1046 |
+
h3 { font-size: 1.2rem; color: #164B41; margin-top: 20px; }
|
| 1047 |
.section { margin-bottom: 30px; padding: 20px; background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; }
|
| 1048 |
form { margin-bottom: 20px; }
|
| 1049 |
label { font-weight: 500; margin-top: 10px; display: block; color: #44524c; font-size: 0.9rem;}
|
| 1050 |
input[type="text"], input[type="number"], input[type="password"], textarea, select { width: 100%; padding: 10px 12px; margin-top: 5px; border: 1px solid #c4d9d1; border-radius: 6px; font-size: 0.95rem; box-sizing: border-box; transition: border-color 0.3s ease; }
|
| 1051 |
+
input:focus, textarea:focus, select:focus { border-color: #1C6758; outline: none; box-shadow: 0 0 0 2px rgba(28, 103, 88, 0.1); }
|
| 1052 |
textarea { min-height: 80px; resize: vertical; }
|
| 1053 |
input[type="file"] { padding: 8px; background-color: #f0f9f4; cursor: pointer; border: 1px solid #c4d9d1;}
|
| 1054 |
input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #e0f0e9; border: 1px solid #c4d9d1; cursor: pointer; margin-right: 10px;}
|
| 1055 |
+
button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #1C6758; color: white; font-weight: 500; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 15px; font-size: 0.95rem; display: inline-flex; align-items: center; gap: 5px; text-decoration: none; line-height: 1.2;}
|
| 1056 |
+
button:hover, .button:hover { background-color: #164B41; }
|
| 1057 |
button:active, .button:active { transform: scale(0.98); }
|
| 1058 |
button[type="submit"] { min-width: 120px; justify-content: center; }
|
| 1059 |
+
.delete-button { background-color: #f56565; }
|
| 1060 |
.delete-button:hover { background-color: #e53e3e; }
|
| 1061 |
+
.add-button { background-color: #38a169; }
|
| 1062 |
.add-button:hover { background-color: #2f855a; }
|
| 1063 |
.item-list { display: grid; gap: 20px; }
|
| 1064 |
.item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #e1f0e9; }
|
|
|
|
| 1066 |
.item strong { color: #2d332f; }
|
| 1067 |
.item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
|
| 1068 |
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; }
|
|
|
|
| 1069 |
.item-actions button:not(.delete-button) { background-color: #1C6758; }
|
| 1070 |
.item-actions button:not(.delete-button):hover { background-color: #164B41; }
|
| 1071 |
+
.edit-form-container { margin-top: 15px; padding: 20px; background: #f0f9f4; border: 1px dashed #c4d9d1; border-radius: 6px; display: none; }
|
| 1072 |
details { background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; margin-bottom: 20px; }
|
| 1073 |
+
details > summary { cursor: pointer; font-weight: 600; color: #164B41; display: block; padding: 15px; border-bottom: 1px solid #d1e7dd; list-style: none; position: relative; }
|
| 1074 |
+
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; }
|
| 1075 |
details[open] > summary::after { transform: translateY(-50%) rotate(180deg); }
|
| 1076 |
details[open] > summary { border-bottom: 1px solid #d1e7dd; }
|
| 1077 |
details .form-content { padding: 20px; }
|
| 1078 |
.color-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
| 1079 |
.color-input-group input { flex-grow: 1; margin: 0; }
|
| 1080 |
+
.remove-color-btn { background-color: #f56565; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; }
|
| 1081 |
.remove-color-btn:hover { background-color: #e53e3e; }
|
|
|
|
| 1082 |
.add-color-btn { background-color: #63b3ed; }
|
| 1083 |
.add-color-btn:hover { background-color: #4299e1; }
|
| 1084 |
.photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #d1e7dd; object-fit: cover;}
|
| 1085 |
.sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
|
|
|
|
| 1086 |
.download-hf-button { background-color: #7a8d85; }
|
| 1087 |
.download-hf-button:hover { background-color: #5e6e68; }
|
| 1088 |
.flex-container { display: flex; flex-wrap: wrap; gap: 20px; }
|
| 1089 |
+
.flex-item { flex: 1; min-width: 350px; }
|
| 1090 |
.message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;}
|
| 1091 |
+
.message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
|
| 1092 |
+
.message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
|
| 1093 |
+
.message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
|
| 1094 |
</style>
|
| 1095 |
</head>
|
| 1096 |
<body>
|
| 1097 |
<div class="container">
|
| 1098 |
<div class="header">
|
| 1099 |
<h1><i class="fas fa-tools"></i> Админ-панель Soola Cosmetics</h1>
|
| 1100 |
+
<a href="{{ url_for('catalog') }}" class="button" style="background-color: #3D8361;"><i class="fas fa-store"></i> Перейти в каталог</a>
|
| 1101 |
</div>
|
| 1102 |
|
|
|
|
| 1103 |
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 1104 |
{% if messages %}
|
| 1105 |
{% for category, message in messages %}
|
|
|
|
| 1112 |
<h2><i class="fas fa-sync-alt"></i> Синхронизация с Hugging Face</h2>
|
| 1113 |
<div class="sync-buttons">
|
| 1114 |
<form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно загрузить локальные данные на сервер? Это перезапишет данные на сервере.');">
|
| 1115 |
+
<button type="submit" class="button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить на HF</button>
|
| 1116 |
</form>
|
| 1117 |
<form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно скачать данные с сервера? Это перезапишет ваши локальные файлы.');">
|
| 1118 |
+
<button type="submit" class="button download-hf-button" title="Скачать файлы с Hugging Face (перезапишет локальные)"><i class="fas fa-download"></i> Скачать с HF</button>
|
| 1119 |
</form>
|
| 1120 |
</div>
|
| 1121 |
<p style="font-size: 0.85rem; color: #5e6e68;">Резервное копирование на Hugging Face происходит автоматически каждые 30 м��нут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p>
|
|
|
|
| 1123 |
|
| 1124 |
|
| 1125 |
<div class="flex-container">
|
| 1126 |
+
<div class="flex-item">
|
| 1127 |
<div class="section">
|
| 1128 |
<h2><i class="fas fa-tags"></i> Управление категориями</h2>
|
| 1129 |
<details>
|
|
|
|
| 1133 |
<input type="hidden" name="action" value="add_category">
|
| 1134 |
<label for="add_category_name">Название новой категории:</label>
|
| 1135 |
<input type="text" id="add_category_name" name="category_name" required>
|
| 1136 |
+
<button type="submit" class="add-button"><i class="fas fa-plus"></i> Добавить</button>
|
| 1137 |
</form>
|
| 1138 |
</div>
|
| 1139 |
</details>
|
|
|
|
| 1147 |
<form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить категорию \'{{ category }}\'? Товары этой категории будут помечены как \'Без категории\'.');">
|
| 1148 |
<input type="hidden" name="action" value="delete_category">
|
| 1149 |
<input type="hidden" name="category_name" value="{{ category }}">
|
| 1150 |
+
<button type="submit" class="delete-button" style="padding: 5px 10px; font-size: 0.8rem; margin: 0;"><i class="fas fa-trash-alt"></i></button>
|
| 1151 |
</form>
|
| 1152 |
</div>
|
| 1153 |
{% endfor %}
|
|
|
|
| 1158 |
</div>
|
| 1159 |
</div>
|
| 1160 |
|
| 1161 |
+
<div class="flex-item">
|
| 1162 |
<div class="section">
|
| 1163 |
<h2><i class="fas fa-users"></i> Управление пользователями</h2>
|
| 1164 |
<details>
|
|
|
|
| 1179 |
<input type="text" id="country" name="country">
|
| 1180 |
<label for="city">Город:</label>
|
| 1181 |
<input type="text" id="city" name="city">
|
| 1182 |
+
<button type="submit" class="add-button"><i class="fas fa-save"></i> Сохранить пользователя</button>
|
| 1183 |
</form>
|
| 1184 |
</div>
|
| 1185 |
</details>
|
|
|
|
| 1196 |
<form method="POST" style="margin: 0;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя \'{{ login }}\'?');">
|
| 1197 |
<input type="hidden" name="action" value="delete_user">
|
| 1198 |
<input type="hidden" name="login" value="{{ login }}">
|
| 1199 |
+
<button type="submit" class="delete-button"><i class="fas fa-user-slash"></i> Удалить</button>
|
| 1200 |
</form>
|
|
|
|
| 1201 |
</div>
|
| 1202 |
</div>
|
| 1203 |
{% endfor %}
|
|
|
|
| 1210 |
</div>
|
| 1211 |
|
| 1212 |
|
| 1213 |
+
<div class="section">
|
| 1214 |
<h2><i class="fas fa-box-open"></i> Управление товарами</h2>
|
| 1215 |
<details>
|
| 1216 |
<summary><i class="fas fa-plus-circle"></i> Добавить но��ый товар</summary>
|
|
|
|
| 1236 |
<div id="add-color-inputs">
|
| 1237 |
<div class="color-input-group">
|
| 1238 |
<input type="text" name="colors" placeholder="Например: Розовый">
|
| 1239 |
+
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1240 |
</div>
|
| 1241 |
</div>
|
| 1242 |
+
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле для цвета/варианта</button>
|
| 1243 |
<br>
|
| 1244 |
+
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Добавить товар</button>
|
| 1245 |
</form>
|
| 1246 |
</div>
|
| 1247 |
</details>
|
|
|
|
| 1252 |
{% for product in products %}
|
| 1253 |
<div class="item">
|
| 1254 |
<div style="display: flex; gap: 15px; align-items: flex-start;">
|
|
|
|
| 1255 |
<div class="photo-preview" style="flex-shrink: 0;">
|
| 1256 |
{% if product.get('photos') %}
|
| 1257 |
<a href="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" target="_blank" title="Посмотреть первое фото">
|
|
|
|
| 1261 |
<img src="https://via.placeholder.com/70x70.png?text=N/A" alt="Нет фото">
|
| 1262 |
{% endif %}
|
| 1263 |
</div>
|
|
|
|
| 1264 |
<div style="flex-grow: 1;">
|
| 1265 |
+
<h3 style="margin-top: 0; margin-bottom: 5px; color: #2d332f;">{{ product['name'] }}</h3>
|
| 1266 |
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
|
| 1267 |
<p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 1268 |
<p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
|
|
|
|
| 1275 |
</div>
|
| 1276 |
|
| 1277 |
<div class="item-actions">
|
| 1278 |
+
<button type="button" class="button" onclick="toggleEditForm('edit-form-{{ loop.index0 }}')"><i class="fas fa-edit"></i> Редактировать</button>
|
| 1279 |
<form method="POST" style="margin:0;" onsubmit="return confirm('Вы уверены, что хотите удалить товар \'{{ product['name'] }}\'?');">
|
| 1280 |
<input type="hidden" name="action" value="delete_product">
|
| 1281 |
<input type="hidden" name="index" value="{{ loop.index0 }}">
|
| 1282 |
+
<button type="submit" class="delete-button"><i class="fas fa-trash-alt"></i> Удалить</button>
|
| 1283 |
</form>
|
| 1284 |
</div>
|
| 1285 |
|
|
|
|
| 1286 |
<div id="edit-form-{{ loop.index0 }}" class="edit-form-container">
|
| 1287 |
<h4><i class="fas fa-edit"></i> Редактирование: {{ product['name'] }}</h4>
|
| 1288 |
<form method="POST" enctype="multipart/form-data">
|
|
|
|
| 1316 |
{% set current_colors = product.get('colors', []) %}
|
| 1317 |
{% if current_colors and current_colors|select('ne', '')|list|length > 0 %}
|
| 1318 |
{% for color in current_colors %}
|
| 1319 |
+
{% if color.strip() %}
|
| 1320 |
<div class="color-input-group">
|
| 1321 |
<input type="text" name="colors" value="{{ color }}">
|
| 1322 |
+
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1323 |
</div>
|
| 1324 |
{% endif %}
|
| 1325 |
{% endfor %}
|
| 1326 |
{% else %}
|
|
|
|
| 1327 |
<div class="color-input-group">
|
| 1328 |
<input type="text" name="colors" placeholder="Например: Красный">
|
| 1329 |
+
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1330 |
</div>
|
| 1331 |
{% endif %}
|
| 1332 |
</div>
|
| 1333 |
+
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('edit-color-inputs-{{ loop.index0 }}')"><i class="fas fa-palette"></i> Добавить поле для цвета</button>
|
| 1334 |
<br>
|
| 1335 |
+
<button type="submit" class="add-button" style="margin-top: 20px;"><i class="fas fa-save"></i> Сохранить изменения</button>
|
| 1336 |
</form>
|
| 1337 |
</div>
|
| 1338 |
</div>
|
|
|
|
| 1363 |
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1364 |
`;
|
| 1365 |
container.appendChild(newInputGroup);
|
|
|
|
| 1366 |
const newInput = newInputGroup.querySelector('input[name="colors"]');
|
| 1367 |
if (newInput) {
|
| 1368 |
newInput.focus();
|
|
|
|
| 1371 |
}
|
| 1372 |
|
| 1373 |
function removeColorInput(button) {
|
|
|
|
| 1374 |
const group = button.closest('.color-input-group');
|
| 1375 |
if (group) {
|
| 1376 |
const container = group.parentNode;
|
| 1377 |
group.remove();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1378 |
} else {
|
| 1379 |
console.warn("Не удалось найти родительский .color-input-group для кнопки удаления");
|
| 1380 |
}
|
|
|
|
| 1386 |
|
| 1387 |
@app.route('/admin', methods=['GET', 'POST'])
|
| 1388 |
def admin():
|
| 1389 |
+
if not session.get('user'):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1390 |
flash("Требуется вход для доступа к админ-панели.", 'warning')
|
| 1391 |
return redirect(url_for('login'))
|
|
|
|
| 1392 |
|
| 1393 |
data = load_data()
|
| 1394 |
products = data.get('products', [])
|
|
|
|
| 1404 |
category_name = request.form.get('category_name', '').strip()
|
| 1405 |
if category_name and category_name not in categories:
|
| 1406 |
categories.append(category_name)
|
| 1407 |
+
categories.sort()
|
| 1408 |
save_data(data)
|
| 1409 |
logging.info(f"Категория '{category_name}' добавлена.")
|
| 1410 |
flash(f"Категория '{category_name}' успешно добавлена.", 'success')
|
|
|
|
| 1419 |
category_to_delete = request.form.get('category_name')
|
| 1420 |
if category_to_delete and category_to_delete in categories:
|
| 1421 |
categories.remove(category_to_delete)
|
|
|
|
| 1422 |
updated_count = 0
|
| 1423 |
for product in products:
|
| 1424 |
if product.get('category') == category_to_delete:
|
|
|
|
| 1442 |
|
| 1443 |
if not name or not price_str:
|
| 1444 |
flash("Название и цена товара обязательны.", 'error')
|
| 1445 |
+
return redirect(url_for('admin'))
|
| 1446 |
|
| 1447 |
try:
|
| 1448 |
price = round(float(price_str), 2)
|
|
|
|
| 1465 |
break
|
| 1466 |
if photo and photo.filename:
|
| 1467 |
try:
|
|
|
|
| 1468 |
ext = os.path.splitext(photo.filename)[1]
|
| 1469 |
photo_filename = secure_filename(f"{name.replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
|
| 1470 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
|
|
|
| 1472 |
logging.info(f"Загрузка фото {photo_filename} на HF для товара {name}...")
|
| 1473 |
api.upload_file(
|
| 1474 |
path_or_fileobj=temp_path,
|
| 1475 |
+
path_in_repo=f"photos/{photo_filename}",
|
| 1476 |
repo_id=REPO_ID,
|
| 1477 |
repo_type="dataset",
|
| 1478 |
token=HF_TOKEN_WRITE,
|
|
|
|
| 1487 |
flash(f"Ошибка при загрузке фото {photo.filename}.", 'error')
|
| 1488 |
elif photo and not photo.filename:
|
| 1489 |
logging.warning("Получен пустой объект файла фото при добавлении товара.")
|
|
|
|
| 1490 |
try:
|
| 1491 |
if not os.listdir(uploads_dir):
|
| 1492 |
os.rmdir(uploads_dir)
|
|
|
|
| 1500 |
'photos': photos_list, 'colors': colors
|
| 1501 |
}
|
| 1502 |
products.append(new_product)
|
|
|
|
| 1503 |
products.sort(key=lambda x: x.get('name', '').lower())
|
| 1504 |
save_data(data)
|
| 1505 |
logging.info(f"Товар '{name}' добавлен.")
|
|
|
|
| 1520 |
flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
|
| 1521 |
return redirect(url_for('admin'))
|
| 1522 |
|
|
|
|
| 1523 |
product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
|
| 1524 |
price_str = request.form.get('price', str(product_to_edit['price'])).replace(',', '.')
|
| 1525 |
product_to_edit['description'] = request.form.get('description', product_to_edit['description']).strip()
|
|
|
|
| 1535 |
logging.warning(f"Неверный формат цены '{price_str}' при редактировании товара {original_name}. Цена не изменена.")
|
| 1536 |
flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning')
|
| 1537 |
|
|
|
|
| 1538 |
photos_files = request.files.getlist('photos')
|
| 1539 |
if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
|
| 1540 |
uploads_dir = 'uploads_temp'
|
|
|
|
| 1566 |
except Exception as e:
|
| 1567 |
logging.error(f"Ошибка загрузки нового фото {photo.filename}: {e}", exc_info=True)
|
| 1568 |
flash(f"Ошибка при загрузке нового фото {photo.filename}.", 'error')
|
|
|
|
| 1569 |
try:
|
| 1570 |
if not os.listdir(uploads_dir):
|
| 1571 |
os.rmdir(uploads_dir)
|
| 1572 |
except OSError as e:
|
| 1573 |
logging.warning(f"Не удалось удалить временную папку {uploads_dir}: {e}")
|
| 1574 |
|
|
|
|
| 1575 |
if new_photos_list:
|
| 1576 |
logging.info(f"Список фото для товара {product_to_edit['name']} обновлен.")
|
|
|
|
|
|
|
| 1577 |
old_photos = product_to_edit.get('photos', [])
|
| 1578 |
if old_photos:
|
| 1579 |
logging.info(f"Попытка удаления старых фото: {old_photos}")
|
|
|
|
| 1589 |
except Exception as e:
|
| 1590 |
logging.error(f"Ошибка при удалении старых фото {old_photos} с HF: {e}", exc_info=True)
|
| 1591 |
flash("Не удалось удалить старые фотографии с сервера.", "warning")
|
|
|
|
| 1592 |
product_to_edit['photos'] = new_photos_list
|
| 1593 |
flash("Фотографии товара успешно обновлены.", "success")
|
| 1594 |
elif uploaded_count == 0 and any(f.filename for f in photos_files):
|
| 1595 |
flash("Не удалось загрузить новые фотографии.", "error")
|
| 1596 |
|
|
|
|
| 1597 |
products.sort(key=lambda x: x.get('name', '').lower())
|
| 1598 |
save_data(data)
|
| 1599 |
logging.info(f"Товар '{original_name}' (индекс {index}) обновлен на '{product_to_edit['name']}'.")
|
|
|
|
| 1611 |
deleted_product = products.pop(index)
|
| 1612 |
product_name = deleted_product.get('name', 'N/A')
|
| 1613 |
|
|
|
|
| 1614 |
photos_to_delete = deleted_product.get('photos', [])
|
| 1615 |
if photos_to_delete and HF_TOKEN_WRITE:
|
| 1616 |
logging.info(f"Попытка удаления фото товара '{product_name}' с HF: {photos_to_delete}")
|
|
|
|
| 1627 |
except Exception as e:
|
| 1628 |
logging.error(f"Ошибка при удалении фото {photos_to_delete} для товара '{product_name}' с HF: {e}", exc_info=True)
|
| 1629 |
flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning")
|
|
|
|
| 1630 |
|
| 1631 |
save_data(data)
|
| 1632 |
logging.info(f"Товар '{product_name}' (индекс {index}) удален.")
|
|
|
|
| 1650 |
flash(f"Пользователь с логином '{login}' уже существует.", 'error')
|
| 1651 |
return redirect(url_for('admin'))
|
| 1652 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1653 |
users[login] = {
|
| 1654 |
+
'password': password,
|
| 1655 |
'first_name': first_name, 'last_name': last_name,
|
| 1656 |
'country': country, 'city': city
|
| 1657 |
}
|
|
|
|
| 1674 |
logging.warning(f"Получено неизвестное действие в админ-панели: {action}")
|
| 1675 |
flash(f"Неизвестное действие: {action}", 'warning')
|
| 1676 |
|
|
|
|
| 1677 |
return redirect(url_for('admin'))
|
| 1678 |
|
| 1679 |
except Exception as e:
|
| 1680 |
logging.error(f"Ошибка при обработке действия '{action}' в админ-панели: {e}", exc_info=True)
|
| 1681 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1682 |
+
return redirect(url_for('admin'))
|
| 1683 |
|
|
|
|
|
|
|
| 1684 |
products.sort(key=lambda x: x.get('name', '').lower())
|
|
|
|
| 1685 |
categories.sort()
|
|
|
|
| 1686 |
sorted_users = dict(sorted(users.items()))
|
| 1687 |
|
| 1688 |
return render_template_string(
|
| 1689 |
+
ADMIN_TEMPLATE,
|
| 1690 |
products=products,
|
| 1691 |
categories=categories,
|
| 1692 |
+
users=sorted_users,
|
| 1693 |
repo_id=REPO_ID,
|
| 1694 |
currency_code=CURRENCY_CODE
|
| 1695 |
)
|
| 1696 |
|
|
|
|
|
|
|
| 1697 |
@app.route('/force_upload', methods=['POST'])
|
| 1698 |
def force_upload():
|
|
|
|
| 1699 |
if not session.get('user'):
|
| 1700 |
flash("Требуется вход для выполнения этого действия.", 'warning')
|
| 1701 |
return redirect(url_for('login'))
|
|
|
|
| 1711 |
|
| 1712 |
@app.route('/force_download', methods=['POST'])
|
| 1713 |
def force_download():
|
|
|
|
| 1714 |
if not session.get('user'):
|
| 1715 |
flash("Требуется вход для выполнения этого действия.", 'warning')
|
| 1716 |
return redirect(url_for('login'))
|
|
|
|
| 1725 |
return redirect(url_for('admin'))
|
| 1726 |
|
| 1727 |
|
|
|
|
|
|
|
| 1728 |
if __name__ == '__main__':
|
|
|
|
| 1729 |
load_data()
|
| 1730 |
load_users()
|
| 1731 |
|
|
|
|
| 1732 |
if HF_TOKEN_WRITE:
|
| 1733 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
| 1734 |
backup_thread.start()
|
|
|
|
| 1736 |
else:
|
| 1737 |
logging.warning("Периодическое резервное копирование НЕ будет запущено (HF_TOKEN или HF_TOKEN_WRITE не установлена).")
|
| 1738 |
|
| 1739 |
+
port = int(os.environ.get('PORT', 7860))
|
|
|
|
| 1740 |
logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1741 |
app.run(debug=False, host='0.0.0.0', port=port)
|