Update app.py
Browse files
app.py
CHANGED
|
@@ -8,14 +8,16 @@ import threading
|
|
| 8 |
import time
|
| 9 |
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 |
from dotenv import load_dotenv
|
|
|
|
| 14 |
|
| 15 |
load_dotenv()
|
| 16 |
|
| 17 |
app = Flask(__name__)
|
| 18 |
-
|
|
|
|
| 19 |
DATA_FILE = 'data_soola.json'
|
| 20 |
USERS_FILE = 'users_soola.json'
|
| 21 |
|
|
@@ -26,118 +28,147 @@ HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
|
|
| 26 |
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
| 27 |
|
| 28 |
STORE_ADDRESS = "Рынок Дордой, Джунхай, терминал, 38"
|
| 29 |
-
|
| 30 |
CURRENCY_CODE = 'KGS'
|
| 31 |
CURRENCY_NAME = 'Кыргызский сом (с)'
|
| 32 |
|
| 33 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
def load_data():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
try:
|
| 37 |
-
|
| 38 |
-
with open(DATA_FILE, 'r', encoding='utf-8') as file:
|
| 39 |
data = json.load(file)
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
if not os.path.exists(DATA_FILE):
|
| 53 |
-
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f)
|
| 54 |
-
logging.info(f"Создан пустой файл {DATA_FILE}")
|
| 55 |
-
return {'products': [], 'categories': []}
|
| 56 |
-
else:
|
| 57 |
-
with open(DATA_FILE, 'r', encoding='utf-8') as file:
|
| 58 |
-
data = json.load(file)
|
| 59 |
-
logging.info(f"Данные успешно загружены из {DATA_FILE} после попытки скачивания.")
|
| 60 |
-
if not isinstance(data, dict): return {'products': [], 'categories': []}
|
| 61 |
-
if 'products' not in data: data['products'] = []
|
| 62 |
-
if 'categories' not in data: data['categories'] = []
|
| 63 |
-
return data
|
| 64 |
-
except (FileNotFoundError, RepositoryNotFoundError) as e:
|
| 65 |
-
logging.warning(f"Файл {DATA_FILE} не найден локально и ошибка при доступе к репозиторию HF ({e}). Создание пустой структуры.")
|
| 66 |
-
if not os.path.exists(DATA_FILE):
|
| 67 |
-
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f)
|
| 68 |
-
return {'products': [], 'categories': []}
|
| 69 |
-
except json.JSONDecodeError:
|
| 70 |
-
logging.error(f"Ошибка декодирования JSON в {DATA_FILE} после попытки скачивания.")
|
| 71 |
-
return {'products': [], 'categories': []}
|
| 72 |
-
except Exception as e:
|
| 73 |
-
logging.error(f"Неизвестная ошибка при загрузке данных после попытки скачивания: {e}")
|
| 74 |
-
return {'products': [], 'categories': []}
|
| 75 |
except json.JSONDecodeError:
|
| 76 |
-
logging.error(f"
|
| 77 |
-
|
|
|
|
|
|
|
| 78 |
except Exception as e:
|
| 79 |
-
logging.error(f"
|
| 80 |
-
return
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
try:
|
| 85 |
-
with open(
|
| 86 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
| 89 |
except Exception as e:
|
| 90 |
-
logging.error(f"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
def load_users():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
try:
|
| 94 |
-
with open(
|
| 95 |
users = json.load(file)
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
except FileNotFoundError:
|
| 99 |
-
logging.warning(f"Локальный файл {USERS_FILE} не найден. Попытка скачать с HF.")
|
| 100 |
-
try:
|
| 101 |
-
download_db_from_hf(specific_file=USERS_FILE)
|
| 102 |
-
with open(USERS_FILE, 'r', encoding='utf-8') as file:
|
| 103 |
-
users = json.load(file)
|
| 104 |
-
logging.info(f"Данные пользователей загружены из {USERS_FILE} после скачивания.")
|
| 105 |
-
return users if isinstance(users, dict) else {}
|
| 106 |
-
except (FileNotFoundError, RepositoryNotFoundError):
|
| 107 |
-
logging.warning(f"Файл {USERS_FILE} не найден локально и в репозитории HF. Создание пустого файла.")
|
| 108 |
-
with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f)
|
| 109 |
-
return {}
|
| 110 |
-
except json.JSONDecodeError:
|
| 111 |
-
logging.error(f"Ошибка декодирования JSON в {USERS_FILE} после скачивания.")
|
| 112 |
-
return {}
|
| 113 |
-
except Exception as e:
|
| 114 |
-
logging.error(f"Неизвестная ошибка при загрузке пользователей после скачивания: {e}", exc_info=True)
|
| 115 |
-
return {}
|
| 116 |
except json.JSONDecodeError:
|
| 117 |
-
logging.error(f"
|
| 118 |
-
return
|
| 119 |
except Exception as e:
|
| 120 |
-
logging.error(f"
|
| 121 |
-
return
|
| 122 |
|
| 123 |
def save_users(users):
|
| 124 |
-
|
| 125 |
-
with open(USERS_FILE, 'w', encoding='utf-8') as file:
|
| 126 |
-
json.dump(users, file, ensure_ascii=False, indent=4)
|
| 127 |
-
logging.info(f"Данные пользователей успешно сохранены в {USERS_FILE}")
|
| 128 |
upload_db_to_hf(specific_file=USERS_FILE)
|
| 129 |
-
except Exception as e:
|
| 130 |
-
logging.error(f"Ошибка при сохранении данных пользователей в {USERS_FILE}: {e}", exc_info=True)
|
| 131 |
-
|
| 132 |
|
| 133 |
def upload_db_to_hf(specific_file=None):
|
| 134 |
if not HF_TOKEN_WRITE:
|
| 135 |
-
logging.warning("
|
| 136 |
return
|
| 137 |
try:
|
| 138 |
api = HfApi()
|
| 139 |
files_to_upload = [specific_file] if specific_file else SYNC_FILES
|
| 140 |
-
logging.info(f"
|
| 141 |
|
| 142 |
for file_name in files_to_upload:
|
| 143 |
if os.path.exists(file_name):
|
|
@@ -150,66 +181,29 @@ def upload_db_to_hf(specific_file=None):
|
|
| 150 |
token=HF_TOKEN_WRITE,
|
| 151 |
commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
| 152 |
)
|
| 153 |
-
logging.info(f"
|
| 154 |
except Exception as e:
|
| 155 |
-
logging.error(f"
|
| 156 |
else:
|
| 157 |
-
logging.warning(f"
|
| 158 |
-
logging.info("
|
| 159 |
-
except Exception as e:
|
| 160 |
-
logging.error(f"Общая ошибка при инициализации или загрузке на Hugging Face: {e}", exc_info=True)
|
| 161 |
-
|
| 162 |
-
def download_db_from_hf(specific_file=None):
|
| 163 |
-
if not HF_TOKEN_READ:
|
| 164 |
-
logging.warning("Переменная окружения HF_TOKEN_READ не установлена. Попытка скачивания с Hugging Face без токена (может не сработать для приватных репо).")
|
| 165 |
-
|
| 166 |
-
files_to_download = [specific_file] if specific_file else SYNC_FILES
|
| 167 |
-
logging.info(f"Начало скачивания файлов {files_to_download} с HF репозитория {REPO_ID}...")
|
| 168 |
-
downloaded_files_count = 0
|
| 169 |
-
try:
|
| 170 |
-
for file_name in files_to_download:
|
| 171 |
-
try:
|
| 172 |
-
local_path = hf_hub_download(
|
| 173 |
-
repo_id=REPO_ID,
|
| 174 |
-
filename=file_name,
|
| 175 |
-
repo_type="dataset",
|
| 176 |
-
token=HF_TOKEN_READ,
|
| 177 |
-
local_dir=".",
|
| 178 |
-
local_dir_use_symlinks=False,
|
| 179 |
-
force_download=True
|
| 180 |
-
)
|
| 181 |
-
logging.info(f"Файл {file_name} успешно скачан из Hugging Face в {local_path}.")
|
| 182 |
-
downloaded_files_count += 1
|
| 183 |
-
except RepositoryNotFoundError:
|
| 184 |
-
logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.")
|
| 185 |
-
break
|
| 186 |
-
except Exception as e:
|
| 187 |
-
if "404" in str(e) or isinstance(e, FileNotFoundError):
|
| 188 |
-
logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск скачивания этого файла.")
|
| 189 |
-
else:
|
| 190 |
-
logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True)
|
| 191 |
-
logging.info(f"Скачивание файлов с HF завершено. Скачано файлов: {downloaded_files_count}/{len(files_to_download)}.")
|
| 192 |
-
except RepositoryNotFoundError:
|
| 193 |
-
logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание не выполнено.")
|
| 194 |
except Exception as e:
|
| 195 |
-
logging.error(f"
|
| 196 |
-
|
| 197 |
|
| 198 |
def periodic_backup():
|
| 199 |
-
backup_interval = 1800
|
| 200 |
-
logging.info(f"
|
| 201 |
while True:
|
| 202 |
time.sleep(backup_interval)
|
| 203 |
-
logging.info("
|
| 204 |
upload_db_to_hf()
|
| 205 |
-
logging.info("
|
| 206 |
-
|
| 207 |
|
| 208 |
@app.route('/')
|
| 209 |
def catalog():
|
| 210 |
data = load_data()
|
| 211 |
all_products = data.get('products', [])
|
| 212 |
-
categories = data.get('categories', [])
|
| 213 |
is_authenticated = 'user' in session
|
| 214 |
|
| 215 |
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
|
@@ -245,7 +239,6 @@ def catalog():
|
|
| 245 |
body.dark-mode .theme-toggle:hover { color: #55a683; }
|
| 246 |
.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; }
|
| 247 |
body.dark-mode .store-address { background-color: #253f37; color: #b0c8c1; }
|
| 248 |
-
|
| 249 |
.filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
| 250 |
.search-container { margin: 20px 0; text-align: center; }
|
| 251 |
#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; }
|
|
@@ -256,9 +249,11 @@ def catalog():
|
|
| 256 |
body.dark-mode .category-filter { background-color: #253f37; border-color: #2c4a41; color: #97b7ae; }
|
| 257 |
.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); }
|
| 258 |
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); }
|
| 259 |
-
|
| 260 |
.products-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; padding: 10px; }
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
| 262 |
body.dark-mode .product { background: #253f37; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #2c4a41; }
|
| 263 |
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
|
| 264 |
body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); }
|
|
@@ -276,13 +271,11 @@ def catalog():
|
|
| 276 |
.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; }
|
| 277 |
.product-button:hover { background-color: #164B41; box-shadow: 0 4px 15px rgba(22, 75, 65, 0.4); transform: translateY(-2px); }
|
| 278 |
.product-button i { margin-right: 5px; }
|
| 279 |
-
|
| 280 |
.add-to-cart { background-color: #38a169; }
|
| 281 |
.add-to-cart:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
|
| 282 |
#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; }
|
| 283 |
#cart-button .fa-shopping-cart { margin-right: 0; }
|
| 284 |
#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; }
|
| 285 |
-
|
| 286 |
.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; }
|
| 287 |
.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; }
|
| 288 |
body.dark-mode .modal-content { background: #253f37; color: #c8d8d3; }
|
|
@@ -315,13 +308,11 @@ def catalog():
|
|
| 315 |
.clear-cart:hover { background-color: #5e6e68; box-shadow: 0 4px 15px rgba(94, 110, 104, 0.4); }
|
| 316 |
.order-button { background-color: #38a169; }
|
| 317 |
.order-button:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
|
| 318 |
-
|
| 319 |
.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;}
|
| 320 |
.notification.show { opacity: 1;}
|
| 321 |
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #5e6e68; }
|
| 322 |
body.dark-mode .no-results-message { color: #8aa39a; }
|
| 323 |
.top-product-indicator { position: absolute; top: 8px; right: 8px; background-color: rgba(255, 215, 0, 0.8); color: #333; padding: 2px 6px; font-size: 0.7rem; border-radius: 4px; font-weight: bold; z-index: 10; backdrop-filter: blur(2px); }
|
| 324 |
-
.product { position: relative; }
|
| 325 |
|
| 326 |
</style>
|
| 327 |
</head>
|
|
@@ -358,6 +349,7 @@ def catalog():
|
|
| 358 |
<div class="products-grid" id="products-grid">
|
| 359 |
{% for product in products %}
|
| 360 |
<div class="product"
|
|
|
|
| 361 |
data-name="{{ product['name']|lower }}"
|
| 362 |
data-description="{{ product.get('description', '')|lower }}"
|
| 363 |
data-category="{{ product.get('category', 'Без категории') }}">
|
|
@@ -445,7 +437,7 @@ def catalog():
|
|
| 445 |
|
| 446 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
|
| 447 |
<script>
|
| 448 |
-
|
| 449 |
const repoId = '{{ repo_id }}';
|
| 450 |
const currencyCode = '{{ currency_code }}';
|
| 451 |
const isAuthenticated = {{ is_authenticated|tojson }};
|
|
@@ -468,6 +460,10 @@ def catalog():
|
|
| 468 |
document.body.classList.add('dark-mode');
|
| 469 |
const icon = document.querySelector('.theme-toggle i');
|
| 470 |
if (icon) icon.classList.replace('fa-moon', 'fa-sun');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
}
|
| 472 |
}
|
| 473 |
|
|
@@ -569,13 +565,13 @@ def catalog():
|
|
| 569 |
const colorLabel = document.querySelector('label[for="colorSelect"]');
|
| 570 |
colorSelect.innerHTML = '';
|
| 571 |
|
| 572 |
-
const validColors = product.colors ? product.colors.filter(c => c && c.trim() !== "") : [];
|
| 573 |
|
| 574 |
if (validColors.length > 0) {
|
| 575 |
validColors.forEach(color => {
|
| 576 |
const option = document.createElement('option');
|
| 577 |
-
option.value = color.trim();
|
| 578 |
-
option.text = color.trim();
|
| 579 |
colorSelect.appendChild(option);
|
| 580 |
});
|
| 581 |
colorSelect.style.display = 'block';
|
|
@@ -594,7 +590,11 @@ def catalog():
|
|
| 594 |
}
|
| 595 |
|
| 596 |
function confirmAddToCart() {
|
| 597 |
-
if (selectedProductIndex === null)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
|
| 599 |
const quantityInput = document.getElementById('quantityInput');
|
| 600 |
const quantity = parseInt(quantityInput.value);
|
|
@@ -664,7 +664,7 @@ def catalog():
|
|
| 664 |
cartTotalElement.textContent = '0.00';
|
| 665 |
} else {
|
| 666 |
cartContent.innerHTML = cart.map(item => {
|
| 667 |
-
const itemTotal = item.price * item.quantity;
|
| 668 |
total += itemTotal;
|
| 669 |
const photoUrl = item.photo
|
| 670 |
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}`
|
|
@@ -676,7 +676,7 @@ def catalog():
|
|
| 676 |
<img src="${photoUrl}" alt="${item.name}">
|
| 677 |
<div class="cart-item-details">
|
| 678 |
<strong>${item.name}${colorText}</strong>
|
| 679 |
-
<p class="cart-item-price">${item.price.toFixed(2)} ${currencyCode} × ${item.quantity}</p>
|
| 680 |
</div>
|
| 681 |
<span class="cart-item-total">${itemTotal.toFixed(2)} ${currencyCode}</span>
|
| 682 |
<button class="cart-item-remove" onclick="removeFromCart('${item.id}')" title="Удалить товар">×</button>
|
|
@@ -714,40 +714,40 @@ def catalog():
|
|
| 714 |
return;
|
| 715 |
}
|
| 716 |
let total = 0;
|
| 717 |
-
let orderText = "🛍️ *Новый Заказ от Soola Cosmetics*
|
| 718 |
-
orderText += "
|
| 719 |
-
orderText += "*Детали
|
| 720 |
-
orderText += "
|
| 721 |
cart.forEach((item, index) => {
|
| 722 |
-
const itemTotal = item.price * item.quantity;
|
| 723 |
total += itemTotal;
|
| 724 |
const colorText = item.color !== 'N/A' ? ` (${item.color})` : '';
|
| 725 |
-
orderText += `${index + 1}. *${item.name}*${colorText}
|
| 726 |
-
orderText += ` Кол-во: ${item.quantity}
|
| 727 |
-
orderText += ` Цена: ${item.price.toFixed(2)} ${currencyCode}
|
| 728 |
-
orderText += ` *Сумма: ${itemTotal.toFixed(2)} ${currencyCode}
|
| 729 |
});
|
| 730 |
-
orderText += "
|
| 731 |
-
orderText += `*ИТОГО: ${total.toFixed(2)} ${currencyCode}
|
| 732 |
-
orderText += "
|
| 733 |
|
| 734 |
if (userInfo && userInfo.login) {
|
| 735 |
-
orderText += "*Данные
|
| 736 |
-
orderText += `Имя: ${userInfo.first_name || ''} ${userInfo.last_name || ''}
|
| 737 |
-
orderText += `Логин: ${userInfo.login}
|
| 738 |
if (userInfo.phone) {
|
| 739 |
-
orderText += `Телефон: ${userInfo.phone}
|
| 740 |
}
|
| 741 |
-
orderText += `Страна: ${userInfo.country || 'Не указана'}
|
| 742 |
-
orderText += `Город: ${userInfo.city || 'Не указан'}
|
| 743 |
} else {
|
| 744 |
-
orderText += "*Клиент не
|
| 745 |
}
|
| 746 |
-
orderText += "
|
| 747 |
|
| 748 |
const now = new Date();
|
| 749 |
const dateTimeString = now.toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
| 750 |
-
orderText += `Дата заказа: ${dateTimeString}
|
| 751 |
orderText += `_Сформировано автоматически_`;
|
| 752 |
|
| 753 |
const whatsappNumber = "996997703090";
|
|
@@ -766,9 +766,9 @@ def catalog():
|
|
| 766 |
if (existingNoResults) existingNoResults.remove();
|
| 767 |
|
| 768 |
document.querySelectorAll('.products-grid .product').forEach(productElement => {
|
| 769 |
-
const name = productElement.getAttribute('data-name');
|
| 770 |
-
const description = productElement.getAttribute('data-description');
|
| 771 |
-
const category = productElement.getAttribute('data-category');
|
| 772 |
|
| 773 |
const matchesSearch = !searchTerm || name.includes(searchTerm) || description.includes(searchTerm);
|
| 774 |
const matchesCategory = activeCategory === 'all' || category === activeCategory;
|
|
@@ -781,17 +781,21 @@ def catalog():
|
|
| 781 |
}
|
| 782 |
});
|
| 783 |
|
| 784 |
-
|
|
|
|
| 785 |
const p = document.createElement('p');
|
| 786 |
p.className = 'no-results-message';
|
| 787 |
p.textContent = 'По вашему запросу товары не найдены.';
|
| 788 |
grid.appendChild(p);
|
| 789 |
-
} else if (
|
| 790 |
const p = document.createElement('p');
|
| 791 |
p.className = 'no-results-message';
|
| 792 |
p.textContent = 'Товары пока не добавлены.';
|
| 793 |
grid.appendChild(p);
|
| 794 |
}
|
|
|
|
|
|
|
|
|
|
| 795 |
}
|
| 796 |
|
| 797 |
function setupFilters() {
|
|
@@ -807,12 +811,15 @@ def catalog():
|
|
| 807 |
filterProducts();
|
| 808 |
});
|
| 809 |
});
|
| 810 |
-
filterProducts();
|
| 811 |
}
|
| 812 |
|
| 813 |
function showNotification(message, duration = 3000) {
|
| 814 |
const placeholder = document.getElementById('notification-placeholder');
|
| 815 |
-
if (!placeholder)
|
|
|
|
|
|
|
|
|
|
| 816 |
|
| 817 |
const notification = document.createElement('div');
|
| 818 |
notification.className = 'notification';
|
|
@@ -829,7 +836,7 @@ def catalog():
|
|
| 829 |
|
| 830 |
document.addEventListener('DOMContentLoaded', () => {
|
| 831 |
applyInitialTheme();
|
| 832 |
-
attemptAutoLogin();
|
| 833 |
updateCartButton();
|
| 834 |
setupFilters();
|
| 835 |
|
|
@@ -846,6 +853,11 @@ def catalog():
|
|
| 846 |
});
|
| 847 |
}
|
| 848 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 849 |
});
|
| 850 |
|
| 851 |
</script>
|
|
@@ -868,16 +880,17 @@ def catalog():
|
|
| 868 |
def product_detail(index):
|
| 869 |
data = load_data()
|
| 870 |
all_products = data.get('products', [])
|
|
|
|
| 871 |
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 872 |
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
| 873 |
|
| 874 |
is_authenticated = 'user' in session
|
| 875 |
try:
|
|
|
|
|
|
|
| 876 |
product = products_sorted[index]
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
except IndexError:
|
| 880 |
-
logging.warning(f"Попытка доступа к несуществующему или отсутствующему продукту с индексом {index}")
|
| 881 |
return "Товар не найден или отсутствует в наличии.", 404
|
| 882 |
|
| 883 |
detail_html = '''
|
|
@@ -917,8 +930,9 @@ def product_detail(index):
|
|
| 917 |
{% endif %}
|
| 918 |
<p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\\n', '<br>')|safe }}</p>
|
| 919 |
{% set colors = product.get('colors', []) %}
|
| 920 |
-
{%
|
| 921 |
-
|
|
|
|
| 922 |
{% endif %}
|
| 923 |
</div>
|
| 924 |
</div>
|
|
@@ -993,54 +1007,17 @@ def login():
|
|
| 993 |
'city': user_info.get('city', ''),
|
| 994 |
'phone': user_info.get('phone', '')
|
| 995 |
}
|
| 996 |
-
logging.info(f"
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
<script>
|
| 1000 |
-
try {{ localStorage.setItem('soolaUser', '{login}'); }} catch (e) {{ console.error("Ошибка сохранения в localStorage:", e); }}
|
| 1001 |
-
window.location.href = "{url_for('catalog')}";
|
| 1002 |
-
</script>
|
| 1003 |
-
<p>Вход выполнен успешно. Перенаправление в <a href="{url_for('catalog')}">каталог</a>...</p>
|
| 1004 |
-
</body></html>
|
| 1005 |
-
'''
|
| 1006 |
-
return login_response_html
|
| 1007 |
else:
|
| 1008 |
-
logging.warning(f"
|
| 1009 |
error_message = "Неверный логин или пароль."
|
| 1010 |
return render_template_string(LOGIN_TEMPLATE, error=error_message), 401
|
| 1011 |
|
| 1012 |
return render_template_string(LOGIN_TEMPLATE, error=None)
|
| 1013 |
|
| 1014 |
-
|
| 1015 |
-
@app.route('/auto_login', methods=['POST'])
|
| 1016 |
-
def auto_login():
|
| 1017 |
-
data = request.get_json()
|
| 1018 |
-
if not data or 'login' not in data:
|
| 1019 |
-
logging.warning("Запрос auto_login без данных или логина.")
|
| 1020 |
-
return "Неверный запрос", 400
|
| 1021 |
-
|
| 1022 |
-
login = data.get('login')
|
| 1023 |
-
if not login:
|
| 1024 |
-
logging.warning("Попытка auto_login с пустым логином.")
|
| 1025 |
-
return "Логин не предоставлен", 400
|
| 1026 |
-
|
| 1027 |
-
users = load_users()
|
| 1028 |
-
if login in users:
|
| 1029 |
-
user_info = users[login]
|
| 1030 |
-
session['user'] = login
|
| 1031 |
-
session['user_info'] = {
|
| 1032 |
-
'login': login,
|
| 1033 |
-
'first_name': user_info.get('first_name', ''),
|
| 1034 |
-
'last_name': user_info.get('last_name', ''),
|
| 1035 |
-
'country': user_info.get('country', ''),
|
| 1036 |
-
'city': user_info.get('city', ''),
|
| 1037 |
-
'phone': user_info.get('phone', '')
|
| 1038 |
-
}
|
| 1039 |
-
logging.info(f"Автоматический вход для пользователя {login} выполнен.")
|
| 1040 |
-
return "OK", 200
|
| 1041 |
-
else:
|
| 1042 |
-
logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}.")
|
| 1043 |
-
return "Ошибка авто-входа", 400
|
| 1044 |
|
| 1045 |
@app.route('/logout')
|
| 1046 |
def logout():
|
|
@@ -1048,17 +1025,10 @@ def logout():
|
|
| 1048 |
session.pop('user', None)
|
| 1049 |
session.pop('user_info', None)
|
| 1050 |
if logged_out_user:
|
| 1051 |
-
logging.info(f"
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
try { localStorage.removeItem('soolaUser'); } catch (e) { console.error("Ошибка удаления из localStorage:", e); }
|
| 1056 |
-
window.location.href = "/";
|
| 1057 |
-
</script>
|
| 1058 |
-
<p>Выход выполнен. Перенаправление на <a href="/">главную страницу</a>...</p>
|
| 1059 |
-
</body></html>
|
| 1060 |
-
'''
|
| 1061 |
-
return logout_response_html
|
| 1062 |
|
| 1063 |
ADMIN_TEMPLATE = '''
|
| 1064 |
<!DOCTYPE html>
|
|
@@ -1097,7 +1067,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1097 |
.add-button:hover { background-color: #2f855a; }
|
| 1098 |
.item-list { display: grid; gap: 20px; }
|
| 1099 |
.item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #e1f0e9; }
|
| 1100 |
-
.item p { margin: 5px 0; font-size: 0.9rem; color: #44524c; }
|
| 1101 |
.item strong { color: #2d332f; }
|
| 1102 |
.item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
|
| 1103 |
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
|
@@ -1154,7 +1124,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1154 |
<button type="submit" class="button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить БД</button>
|
| 1155 |
</form>
|
| 1156 |
<form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно скачать данные с сервера? Это перезапишет ваши локальные файлы.');">
|
| 1157 |
-
<button type="submit" class="button download-hf-button" title="Скачать файлы
|
| 1158 |
</form>
|
| 1159 |
</div>
|
| 1160 |
<p style="font-size: 0.85rem; color: #5e6e68;">Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p>
|
|
@@ -1284,11 +1254,11 @@ ADMIN_TEMPLATE = '''
|
|
| 1284 |
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле для цвета/варианта</button>
|
| 1285 |
<br>
|
| 1286 |
<div style="margin-top: 15px;">
|
| 1287 |
-
<input type="checkbox" id="add_in_stock" name="in_stock" checked>
|
| 1288 |
<label for="add_in_stock" class="inline-label">В наличии</label>
|
| 1289 |
</div>
|
| 1290 |
<div style="margin-top: 5px;">
|
| 1291 |
-
<input type="checkbox" id="add_is_top" name="is_top">
|
| 1292 |
<label for="add_is_top" class="inline-label">Топ товар (показывать наверху)</label>
|
| 1293 |
</div>
|
| 1294 |
<br>
|
|
@@ -1304,7 +1274,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1304 |
<div class="item">
|
| 1305 |
<div style="display: flex; gap: 15px; align-items: flex-start;">
|
| 1306 |
<div class="photo-preview" style="flex-shrink: 0;">
|
| 1307 |
-
{% if product.get('photos') %}
|
| 1308 |
<a href="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" target="_blank" title="Посмотреть первое фото">
|
| 1309 |
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" alt="Фото">
|
| 1310 |
</a>
|
|
@@ -1328,7 +1298,8 @@ ADMIN_TEMPLATE = '''
|
|
| 1328 |
<p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 1329 |
<p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
|
| 1330 |
{% set colors = product.get('colors', []) %}
|
| 1331 |
-
|
|
|
|
| 1332 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 1333 |
<p style="font-size: 0.8rem; color: #5e6e68;">(Всего фото: {{ product['photos']|length }})</p>
|
| 1334 |
{% endif %}
|
|
@@ -1374,15 +1345,13 @@ ADMIN_TEMPLATE = '''
|
|
| 1374 |
{% endif %}
|
| 1375 |
<label>Цвета/Варианты:</label>
|
| 1376 |
<div id="edit-color-inputs-{{ loop.index0 }}">
|
| 1377 |
-
{% set current_colors = product.get('colors', []) %}
|
| 1378 |
-
{% if current_colors
|
| 1379 |
{% for color in current_colors %}
|
| 1380 |
-
{% if color.strip() %}
|
| 1381 |
<div class="color-input-group">
|
| 1382 |
<input type="text" name="colors" value="{{ color }}">
|
| 1383 |
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1384 |
</div>
|
| 1385 |
-
{% endif %}
|
| 1386 |
{% endfor %}
|
| 1387 |
{% else %}
|
| 1388 |
<div class="color-input-group">
|
|
@@ -1394,11 +1363,11 @@ ADMIN_TEMPLATE = '''
|
|
| 1394 |
<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>
|
| 1395 |
<br>
|
| 1396 |
<div style="margin-top: 15px;">
|
| 1397 |
-
<input type="checkbox" id="edit_in_stock_{{ loop.index0 }}" name="in_stock" {% if product.get('in_stock', True) %}checked{% endif %}>
|
| 1398 |
<label for="edit_in_stock_{{ loop.index0 }}" class="inline-label">В наличии</label>
|
| 1399 |
</div>
|
| 1400 |
<div style="margin-top: 5px;">
|
| 1401 |
-
<input type="checkbox" id="edit_is_top_{{ loop.index0 }}" name="is_top" {% if product.get('is_top', False) %}checked{% endif %}>
|
| 1402 |
<label for="edit_is_top_{{ loop.index0 }}" class="inline-label">Топ товар</label>
|
| 1403 |
</div>
|
| 1404 |
<br>
|
|
@@ -1444,21 +1413,19 @@ ADMIN_TEMPLATE = '''
|
|
| 1444 |
const group = button.closest('.color-input-group');
|
| 1445 |
if (group) {
|
| 1446 |
const container = group.parentNode;
|
| 1447 |
-
// Only remove if it's not the last one (or handle adding a placeholder if it is)
|
| 1448 |
-
// For simplicity, let's allow removing all. Add logic if needed later.
|
| 1449 |
group.remove();
|
| 1450 |
-
//
|
| 1451 |
if (container && container.children.length === 0) {
|
| 1452 |
const placeholderGroup = document.createElement('div');
|
| 1453 |
placeholderGroup.className = 'color-input-group';
|
| 1454 |
placeholderGroup.innerHTML = `
|
| 1455 |
-
<input type="text" name="colors" placeholder="
|
| 1456 |
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1457 |
`;
|
| 1458 |
container.appendChild(placeholderGroup);
|
| 1459 |
}
|
| 1460 |
} else {
|
| 1461 |
-
console.warn("
|
| 1462 |
}
|
| 1463 |
}
|
| 1464 |
</script>
|
|
@@ -1468,10 +1435,16 @@ ADMIN_TEMPLATE = '''
|
|
| 1468 |
|
| 1469 |
@app.route('/admin', methods=['GET', 'POST'])
|
| 1470 |
def admin():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1471 |
data = load_data()
|
| 1472 |
-
products = data.get('products', [])
|
| 1473 |
-
categories = data.get('categories', [])
|
| 1474 |
users = load_users()
|
|
|
|
|
|
|
|
|
|
| 1475 |
|
| 1476 |
if request.method == 'POST':
|
| 1477 |
action = request.form.get('action')
|
|
@@ -1480,36 +1453,37 @@ def admin():
|
|
| 1480 |
try:
|
| 1481 |
if action == 'add_category':
|
| 1482 |
category_name = request.form.get('category_name', '').strip()
|
| 1483 |
-
if category_name and category_name not in categories:
|
| 1484 |
-
categories.append(category_name)
|
| 1485 |
-
|
| 1486 |
save_data(data)
|
| 1487 |
-
logging.info(f"
|
| 1488 |
flash(f"Категория '{category_name}' успешно добавлена.", 'success')
|
| 1489 |
elif not category_name:
|
| 1490 |
-
logging.warning("
|
| 1491 |
flash("Название категории не может быть пустым.", 'error')
|
| 1492 |
else:
|
| 1493 |
-
logging.warning(f"
|
| 1494 |
flash(f"Категория '{category_name}' уже существует.", 'error')
|
| 1495 |
|
| 1496 |
elif action == 'delete_category':
|
| 1497 |
category_to_delete = request.form.get('category_name')
|
| 1498 |
-
|
| 1499 |
-
|
|
|
|
| 1500 |
updated_count = 0
|
| 1501 |
-
|
|
|
|
| 1502 |
if product.get('category') == category_to_delete:
|
| 1503 |
product['category'] = 'Без категории'
|
| 1504 |
updated_count += 1
|
| 1505 |
save_data(data)
|
| 1506 |
-
logging.info(f"
|
| 1507 |
flash(f"Категория '{category_to_delete}' удалена.", 'success')
|
| 1508 |
else:
|
| 1509 |
-
logging.warning(f"
|
| 1510 |
flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error')
|
| 1511 |
|
| 1512 |
-
|
| 1513 |
elif action == 'add_product':
|
| 1514 |
name = request.form.get('name', '').strip()
|
| 1515 |
price_str = request.form.get('price', '').replace(',', '.')
|
|
@@ -1517,9 +1491,8 @@ def admin():
|
|
| 1517 |
category = request.form.get('category')
|
| 1518 |
photos_files = request.files.getlist('photos')
|
| 1519 |
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1520 |
-
in_stock = 'in_stock'
|
| 1521 |
-
is_top = 'is_top'
|
| 1522 |
-
|
| 1523 |
|
| 1524 |
if not name or not price_str:
|
| 1525 |
flash("Название и цена товара обязательны.", 'error')
|
|
@@ -1527,64 +1500,64 @@ def admin():
|
|
| 1527 |
|
| 1528 |
try:
|
| 1529 |
price = round(float(price_str), 2)
|
| 1530 |
-
if price < 0:
|
| 1531 |
except ValueError:
|
| 1532 |
-
flash("Неверный формат
|
| 1533 |
return redirect(url_for('admin'))
|
| 1534 |
|
| 1535 |
photos_list = []
|
| 1536 |
-
if photos_files and HF_TOKEN_WRITE:
|
| 1537 |
uploads_dir = 'uploads_temp'
|
| 1538 |
os.makedirs(uploads_dir, exist_ok=True)
|
| 1539 |
api = HfApi()
|
| 1540 |
photo_limit = 10
|
| 1541 |
uploaded_count = 0
|
| 1542 |
for photo in photos_files:
|
| 1543 |
-
|
| 1544 |
-
|
|
|
|
| 1545 |
flash(f"Загружено только первые {photo_limit} фото.", "warning")
|
| 1546 |
break
|
| 1547 |
-
|
| 1548 |
-
|
| 1549 |
-
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
|
| 1556 |
-
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
-
|
| 1560 |
-
|
| 1561 |
-
|
| 1562 |
-
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
|
| 1566 |
-
|
| 1567 |
-
|
| 1568 |
-
|
| 1569 |
-
|
| 1570 |
-
|
| 1571 |
-
|
| 1572 |
-
|
| 1573 |
-
|
|
|
|
| 1574 |
except OSError as e:
|
| 1575 |
-
logging.warning(f"
|
| 1576 |
-
|
| 1577 |
|
| 1578 |
new_product = {
|
| 1579 |
'name': name, 'price': price, 'description': description,
|
| 1580 |
-
'category': category if category in categories else 'Без категории',
|
| 1581 |
'photos': photos_list, 'colors': colors,
|
| 1582 |
'in_stock': in_stock, 'is_top': is_top
|
| 1583 |
}
|
| 1584 |
-
products.append(new_product)
|
| 1585 |
-
|
| 1586 |
save_data(data)
|
| 1587 |
-
logging.info(f"
|
| 1588 |
flash(f"Товар '{name}' успешно добавлен.", 'success')
|
| 1589 |
|
| 1590 |
elif action == 'edit_product':
|
|
@@ -1595,100 +1568,107 @@ def admin():
|
|
| 1595 |
|
| 1596 |
try:
|
| 1597 |
index = int(index_str)
|
| 1598 |
-
|
| 1599 |
-
|
| 1600 |
-
|
| 1601 |
-
if not (0 <= index < len(original_product_list)): raise IndexError("Индекс вне диапазона")
|
| 1602 |
-
product_to_edit = original_product_list[index]
|
| 1603 |
original_name = product_to_edit.get('name', 'N/A')
|
| 1604 |
-
|
| 1605 |
except (ValueError, IndexError):
|
| 1606 |
flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
|
| 1607 |
return redirect(url_for('admin'))
|
| 1608 |
|
| 1609 |
-
|
| 1610 |
-
|
| 1611 |
-
|
|
|
|
| 1612 |
category = request.form.get('category')
|
| 1613 |
-
product_to_edit['category'] = category if category in categories else 'Без категории'
|
| 1614 |
product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1615 |
-
product_to_edit['in_stock'] = 'in_stock'
|
| 1616 |
-
product_to_edit['is_top'] = 'is_top'
|
| 1617 |
-
|
| 1618 |
|
| 1619 |
try:
|
| 1620 |
price = round(float(price_str), 2)
|
| 1621 |
-
if price < 0:
|
| 1622 |
product_to_edit['price'] = price
|
| 1623 |
except ValueError:
|
| 1624 |
-
logging.warning(f"
|
| 1625 |
-
flash(f"Неверный формат цены для товара '{
|
| 1626 |
|
| 1627 |
photos_files = request.files.getlist('photos')
|
| 1628 |
-
|
|
|
|
| 1629 |
uploads_dir = 'uploads_temp'
|
| 1630 |
os.makedirs(uploads_dir, exist_ok=True)
|
| 1631 |
api = HfApi()
|
| 1632 |
new_photos_list = []
|
| 1633 |
photo_limit = 10
|
| 1634 |
uploaded_count = 0
|
| 1635 |
-
logging.info(f"
|
| 1636 |
for photo in photos_files:
|
| 1637 |
-
|
| 1638 |
-
|
|
|
|
| 1639 |
flash(f"Загружено только первые {photo_limit} фото.", "warning")
|
| 1640 |
break
|
| 1641 |
-
|
| 1642 |
-
|
| 1643 |
-
|
| 1644 |
-
|
| 1645 |
-
|
| 1646 |
-
|
| 1647 |
-
|
| 1648 |
-
|
| 1649 |
-
|
| 1650 |
-
|
| 1651 |
-
|
| 1652 |
-
|
| 1653 |
-
|
| 1654 |
-
|
| 1655 |
-
|
| 1656 |
-
|
| 1657 |
-
|
| 1658 |
-
|
| 1659 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1660 |
os.rmdir(uploads_dir)
|
| 1661 |
except OSError as e:
|
| 1662 |
-
|
| 1663 |
|
|
|
|
| 1664 |
if new_photos_list:
|
| 1665 |
-
logging.info(f"
|
| 1666 |
old_photos = product_to_edit.get('photos', [])
|
| 1667 |
if old_photos:
|
| 1668 |
-
logging.info(f"
|
| 1669 |
try:
|
|
|
|
| 1670 |
api.delete_files(
|
| 1671 |
repo_id=REPO_ID,
|
| 1672 |
-
paths_in_repo=[f"photos/{p}" for p in old_photos],
|
| 1673 |
repo_type="dataset",
|
| 1674 |
token=HF_TOKEN_WRITE,
|
| 1675 |
-
commit_message=f"Delete old photos for product {product_to_edit['name']}"
|
|
|
|
| 1676 |
)
|
| 1677 |
-
logging.info(f"
|
| 1678 |
except Exception as e:
|
| 1679 |
-
logging.error(f"
|
| 1680 |
flash("Не удалось удалить старые фотографии с сервера.", "warning")
|
| 1681 |
product_to_edit['photos'] = new_photos_list
|
| 1682 |
flash("Фотографии товара успешно обновлены.", "success")
|
| 1683 |
-
elif uploaded_count == 0 and any(f.filename for f in photos_files):
|
| 1684 |
-
|
| 1685 |
-
|
|
|
|
| 1686 |
|
|
|
|
| 1687 |
save_data(data)
|
| 1688 |
-
logging.info(f"
|
| 1689 |
flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success')
|
| 1690 |
|
| 1691 |
-
|
| 1692 |
elif action == 'delete_product':
|
| 1693 |
index_str = request.form.get('index')
|
| 1694 |
if index_str is None:
|
|
@@ -1696,38 +1676,42 @@ def admin():
|
|
| 1696 |
return redirect(url_for('admin'))
|
| 1697 |
try:
|
| 1698 |
index = int(index_str)
|
| 1699 |
-
|
| 1700 |
-
if not (0 <= index < len(original_product_list)): raise IndexError("
|
| 1701 |
-
|
|
|
|
| 1702 |
product_name = deleted_product.get('name', 'N/A')
|
| 1703 |
|
| 1704 |
photos_to_delete = deleted_product.get('photos', [])
|
| 1705 |
if photos_to_delete and HF_TOKEN_WRITE:
|
| 1706 |
-
logging.info(f"
|
| 1707 |
try:
|
| 1708 |
api = HfApi()
|
| 1709 |
api.delete_files(
|
| 1710 |
repo_id=REPO_ID,
|
| 1711 |
-
paths_in_repo=[f"photos/{p}" for p in photos_to_delete],
|
| 1712 |
repo_type="dataset",
|
| 1713 |
token=HF_TOKEN_WRITE,
|
| 1714 |
-
commit_message=f"Delete photos for deleted product {product_name}"
|
|
|
|
| 1715 |
)
|
| 1716 |
-
logging.info(f"
|
| 1717 |
except Exception as e:
|
| 1718 |
-
logging.error(f"
|
| 1719 |
flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning")
|
| 1720 |
|
| 1721 |
save_data(data)
|
| 1722 |
-
logging.info(f"
|
| 1723 |
flash(f"Товар '{product_name}' удален.", 'success')
|
| 1724 |
except (ValueError, IndexError):
|
| 1725 |
flash(f"Ошибка удаления: неверный индекс товара '{index_str}'.", 'error')
|
| 1726 |
-
|
|
|
|
|
|
|
| 1727 |
|
| 1728 |
elif action == 'add_user':
|
| 1729 |
login = request.form.get('login', '').strip()
|
| 1730 |
-
password = request.form.get('password',
|
| 1731 |
first_name = request.form.get('first_name', '').strip()
|
| 1732 |
last_name = request.form.get('last_name', '').strip()
|
| 1733 |
phone = request.form.get('phone', '').strip()
|
|
@@ -1742,13 +1726,13 @@ def admin():
|
|
| 1742 |
return redirect(url_for('admin'))
|
| 1743 |
|
| 1744 |
users[login] = {
|
| 1745 |
-
'password': password,
|
| 1746 |
'first_name': first_name, 'last_name': last_name,
|
| 1747 |
'phone': phone,
|
| 1748 |
'country': country, 'city': city
|
| 1749 |
}
|
| 1750 |
save_users(users)
|
| 1751 |
-
logging.info(f"
|
| 1752 |
flash(f"Пользователь '{login}' успешно добавлен.", 'success')
|
| 1753 |
|
| 1754 |
elif action == 'delete_user':
|
|
@@ -1756,38 +1740,33 @@ def admin():
|
|
| 1756 |
if login_to_delete and login_to_delete in users:
|
| 1757 |
del users[login_to_delete]
|
| 1758 |
save_users(users)
|
| 1759 |
-
logging.info(f"
|
| 1760 |
flash(f"Пользователь '{login_to_delete}' удален.", 'success')
|
| 1761 |
else:
|
| 1762 |
-
logging.warning(f"
|
| 1763 |
flash(f"Не удалось удалить пользователя '{login_to_delete}'.", 'error')
|
| 1764 |
|
| 1765 |
else:
|
| 1766 |
-
logging.warning(f"
|
| 1767 |
flash(f"Неизвестное действие: {action}", 'warning')
|
| 1768 |
|
|
|
|
| 1769 |
return redirect(url_for('admin'))
|
| 1770 |
|
| 1771 |
except Exception as e:
|
| 1772 |
-
logging.error(f"
|
| 1773 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
|
|
|
| 1774 |
return redirect(url_for('admin'))
|
| 1775 |
|
| 1776 |
-
#
|
| 1777 |
-
|
| 1778 |
-
#
|
| 1779 |
-
display_products = sorted(original_products_with_indices, key=lambda item: item[1].get('name', '').lower())
|
| 1780 |
-
# Reconstruct list of products in display order for the template
|
| 1781 |
-
# Need to pass the original index within the product data or handle it carefully
|
| 1782 |
-
# Let's pass the original product list directly for simplicity in the template loops
|
| 1783 |
-
original_product_list = data.get('products', [])
|
| 1784 |
-
|
| 1785 |
-
categories.sort()
|
| 1786 |
sorted_users = dict(sorted(users.items()))
|
| 1787 |
|
| 1788 |
return render_template_string(
|
| 1789 |
ADMIN_TEMPLATE,
|
| 1790 |
-
products=original_product_list,
|
| 1791 |
categories=categories,
|
| 1792 |
users=sorted_users,
|
| 1793 |
repo_id=REPO_ID,
|
|
@@ -1796,39 +1775,50 @@ def admin():
|
|
| 1796 |
|
| 1797 |
@app.route('/force_upload', methods=['POST'])
|
| 1798 |
def force_upload():
|
| 1799 |
-
|
|
|
|
| 1800 |
try:
|
| 1801 |
upload_db_to_hf()
|
| 1802 |
flash("Данные успешно загружены на Hugging Face.", 'success')
|
| 1803 |
except Exception as e:
|
| 1804 |
-
logging.error(f"
|
| 1805 |
flash(f"Ошибка при загрузке на Hugging Face: {e}", 'error')
|
| 1806 |
return redirect(url_for('admin'))
|
| 1807 |
|
| 1808 |
@app.route('/force_download', methods=['POST'])
|
| 1809 |
def force_download():
|
| 1810 |
-
|
|
|
|
| 1811 |
try:
|
| 1812 |
-
download_db_from_hf()
|
| 1813 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1814 |
except Exception as e:
|
| 1815 |
-
logging.error(f"
|
| 1816 |
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
|
| 1817 |
return redirect(url_for('admin'))
|
| 1818 |
|
| 1819 |
|
| 1820 |
if __name__ == '__main__':
|
|
|
|
| 1821 |
load_data()
|
| 1822 |
load_users()
|
| 1823 |
|
|
|
|
| 1824 |
if HF_TOKEN_WRITE:
|
| 1825 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
| 1826 |
backup_thread.start()
|
| 1827 |
-
logging.info("
|
| 1828 |
else:
|
| 1829 |
-
logging.warning("
|
| 1830 |
|
|
|
|
| 1831 |
port = int(os.environ.get('PORT', 7860))
|
| 1832 |
-
logging.info(f"
|
|
|
|
| 1833 |
app.run(debug=False, host='0.0.0.0', port=port)
|
| 1834 |
|
|
|
|
|
|
| 8 |
import time
|
| 9 |
from datetime import datetime
|
| 10 |
from huggingface_hub import HfApi, hf_hub_download
|
| 11 |
+
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
|
| 12 |
from werkzeug.utils import secure_filename
|
| 13 |
from dotenv import load_dotenv
|
| 14 |
+
import shutil # Added for atomic saves
|
| 15 |
|
| 16 |
load_dotenv()
|
| 17 |
|
| 18 |
app = Flask(__name__)
|
| 19 |
+
# IMPORTANT: Replace this with a strong, unique secret key, preferably stored securely (e.g., environment variable)
|
| 20 |
+
app.secret_key = os.getenv('FLASK_SECRET_KEY', 'replace_this_with_a_real_secret_key_soola_67890')
|
| 21 |
DATA_FILE = 'data_soola.json'
|
| 22 |
USERS_FILE = 'users_soola.json'
|
| 23 |
|
|
|
|
| 28 |
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
| 29 |
|
| 30 |
STORE_ADDRESS = "Рынок Дордой, Джунхай, терминал, 38"
|
|
|
|
| 31 |
CURRENCY_CODE = 'KGS'
|
| 32 |
CURRENCY_NAME = 'Кыргызский сом (с)'
|
| 33 |
|
| 34 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 35 |
|
| 36 |
+
def download_db_from_hf(specific_file=None):
|
| 37 |
+
if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
|
| 38 |
+
logging.warning("Neither HF_TOKEN_READ nor HF_TOKEN_WRITE is set. Attempting download without token (may fail for private repos).")
|
| 39 |
+
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE # Use write token if read token isn't available
|
| 40 |
+
|
| 41 |
+
files_to_download = [specific_file] if specific_file else SYNC_FILES
|
| 42 |
+
logging.info(f"Attempting to download files {files_to_download} from HF repo {REPO_ID}...")
|
| 43 |
+
downloaded_files_count = 0
|
| 44 |
+
all_successful = True
|
| 45 |
+
try:
|
| 46 |
+
for file_name in files_to_download:
|
| 47 |
+
try:
|
| 48 |
+
local_path = hf_hub_download(
|
| 49 |
+
repo_id=REPO_ID,
|
| 50 |
+
filename=file_name,
|
| 51 |
+
repo_type="dataset",
|
| 52 |
+
token=token_to_use,
|
| 53 |
+
local_dir=".",
|
| 54 |
+
local_dir_use_symlinks=False,
|
| 55 |
+
force_download=True,
|
| 56 |
+
cache_dir=None # Avoid caching issues when forcing download
|
| 57 |
+
)
|
| 58 |
+
logging.info(f"File {file_name} successfully downloaded from Hugging Face to {local_path}.")
|
| 59 |
+
downloaded_files_count += 1
|
| 60 |
+
except RepositoryNotFoundError:
|
| 61 |
+
logging.error(f"Repository {REPO_ID} not found on Hugging Face. Download aborted.")
|
| 62 |
+
all_successful = False
|
| 63 |
+
break
|
| 64 |
+
except HfHubHTTPError as e:
|
| 65 |
+
if e.response.status_code == 404:
|
| 66 |
+
logging.warning(f"File {file_name} not found in repository {REPO_ID}. Skipping download for this file.")
|
| 67 |
+
else:
|
| 68 |
+
logging.error(f"HTTP error downloading {file_name} from Hugging Face: {e}", exc_info=True)
|
| 69 |
+
all_successful = False
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logging.error(f"Error downloading file {file_name} from Hugging Face: {e}", exc_info=True)
|
| 72 |
+
all_successful = False
|
| 73 |
+
logging.info(f"HF download process finished. Downloaded {downloaded_files_count}/{len(files_to_download)} files.")
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logging.error(f"General error during Hugging Face download attempt: {e}", exc_info=True)
|
| 76 |
+
all_successful = False
|
| 77 |
+
return all_successful
|
| 78 |
+
|
| 79 |
def load_data():
|
| 80 |
+
file_path = DATA_FILE
|
| 81 |
+
default_structure = {'products': [], 'categories': []}
|
| 82 |
+
download_success = download_db_from_hf(specific_file=file_path)
|
| 83 |
+
|
| 84 |
+
if not os.path.exists(file_path):
|
| 85 |
+
logging.warning(f"Local file {file_path} not found, even after download attempt. Initializing with empty structure.")
|
| 86 |
+
with open(file_path, 'w', encoding='utf-8') as f:
|
| 87 |
+
json.dump(default_structure, f)
|
| 88 |
+
return default_structure
|
| 89 |
+
|
| 90 |
try:
|
| 91 |
+
with open(file_path, 'r', encoding='utf-8') as file:
|
|
|
|
| 92 |
data = json.load(file)
|
| 93 |
+
logging.info(f"Data successfully loaded from {file_path}")
|
| 94 |
+
|
| 95 |
+
if not isinstance(data, dict):
|
| 96 |
+
logging.warning(f"{file_path} content is not a dictionary. Resetting to default structure.")
|
| 97 |
+
return default_structure
|
| 98 |
+
if 'products' not in data or not isinstance(data['products'], list):
|
| 99 |
+
logging.warning(f"'products' key missing or not a list in {file_path}. Initializing.")
|
| 100 |
+
data['products'] = []
|
| 101 |
+
if 'categories' not in data or not isinstance(data['categories'], list):
|
| 102 |
+
logging.warning(f"'categories' key missing or not a list in {file_path}. Initializing.")
|
| 103 |
+
data['categories'] = []
|
| 104 |
+
return data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
except json.JSONDecodeError:
|
| 106 |
+
logging.error(f"JSON decode error in {file_path}. File might be corrupted. Returning default structure.", exc_info=True)
|
| 107 |
+
# Optionally, try to re-download on decode error? Or just return default.
|
| 108 |
+
# For now, returning default to prevent cascading errors.
|
| 109 |
+
return default_structure
|
| 110 |
except Exception as e:
|
| 111 |
+
logging.error(f"Unknown error loading data from {file_path}: {e}", exc_info=True)
|
| 112 |
+
return default_structure
|
| 113 |
|
| 114 |
+
def save_data_atomic(data, file_path):
|
| 115 |
+
temp_file_path = file_path + '.tmp'
|
| 116 |
try:
|
| 117 |
+
with open(temp_file_path, 'w', encoding='utf-8') as file:
|
| 118 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 119 |
+
# Atomically replace the old file with the new one
|
| 120 |
+
shutil.move(temp_file_path, file_path)
|
| 121 |
+
logging.info(f"Data successfully saved atomically to {file_path}")
|
| 122 |
+
return True
|
| 123 |
except Exception as e:
|
| 124 |
+
logging.error(f"Error during atomic save to {file_path}: {e}", exc_info=True)
|
| 125 |
+
# Clean up temp file if it exists
|
| 126 |
+
if os.path.exists(temp_file_path):
|
| 127 |
+
try:
|
| 128 |
+
os.remove(temp_file_path)
|
| 129 |
+
except OSError as rm_err:
|
| 130 |
+
logging.error(f"Error removing temporary file {temp_file_path}: {rm_err}")
|
| 131 |
+
return False
|
| 132 |
+
|
| 133 |
+
def save_data(data):
|
| 134 |
+
if save_data_atomic(data, DATA_FILE):
|
| 135 |
+
upload_db_to_hf(specific_file=DATA_FILE)
|
| 136 |
|
| 137 |
def load_users():
|
| 138 |
+
file_path = USERS_FILE
|
| 139 |
+
default_structure = {}
|
| 140 |
+
download_success = download_db_from_hf(specific_file=file_path)
|
| 141 |
+
|
| 142 |
+
if not os.path.exists(file_path):
|
| 143 |
+
logging.warning(f"Local file {file_path} not found, even after download attempt. Initializing with empty structure.")
|
| 144 |
+
with open(file_path, 'w', encoding='utf-8') as f:
|
| 145 |
+
json.dump(default_structure, f)
|
| 146 |
+
return default_structure
|
| 147 |
+
|
| 148 |
try:
|
| 149 |
+
with open(file_path, 'r', encoding='utf-8') as file:
|
| 150 |
users = json.load(file)
|
| 151 |
+
logging.info(f"User data successfully loaded from {file_path}")
|
| 152 |
+
return users if isinstance(users, dict) else default_structure
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
except json.JSONDecodeError:
|
| 154 |
+
logging.error(f"JSON decode error in {file_path}. File might be corrupted. Returning default structure.", exc_info=True)
|
| 155 |
+
return default_structure
|
| 156 |
except Exception as e:
|
| 157 |
+
logging.error(f"Unknown error loading users from {file_path}: {e}", exc_info=True)
|
| 158 |
+
return default_structure
|
| 159 |
|
| 160 |
def save_users(users):
|
| 161 |
+
if save_data_atomic(users, USERS_FILE):
|
|
|
|
|
|
|
|
|
|
| 162 |
upload_db_to_hf(specific_file=USERS_FILE)
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
def upload_db_to_hf(specific_file=None):
|
| 165 |
if not HF_TOKEN_WRITE:
|
| 166 |
+
logging.warning("HF_TOKEN (for writing) not set. Skipping upload to Hugging Face.")
|
| 167 |
return
|
| 168 |
try:
|
| 169 |
api = HfApi()
|
| 170 |
files_to_upload = [specific_file] if specific_file else SYNC_FILES
|
| 171 |
+
logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
|
| 172 |
|
| 173 |
for file_name in files_to_upload:
|
| 174 |
if os.path.exists(file_name):
|
|
|
|
| 181 |
token=HF_TOKEN_WRITE,
|
| 182 |
commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
| 183 |
)
|
| 184 |
+
logging.info(f"File {file_name} successfully uploaded to Hugging Face.")
|
| 185 |
except Exception as e:
|
| 186 |
+
logging.error(f"Error uploading file {file_name} to Hugging Face: {e}")
|
| 187 |
else:
|
| 188 |
+
logging.warning(f"File {file_name} not found locally, skipping upload.")
|
| 189 |
+
logging.info("HF upload process finished.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
except Exception as e:
|
| 191 |
+
logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
|
|
|
|
| 192 |
|
| 193 |
def periodic_backup():
|
| 194 |
+
backup_interval = 1800 # 30 minutes
|
| 195 |
+
logging.info(f"Setting up periodic backup every {backup_interval} seconds.")
|
| 196 |
while True:
|
| 197 |
time.sleep(backup_interval)
|
| 198 |
+
logging.info("Starting periodic backup...")
|
| 199 |
upload_db_to_hf()
|
| 200 |
+
logging.info("Periodic backup finished.")
|
|
|
|
| 201 |
|
| 202 |
@app.route('/')
|
| 203 |
def catalog():
|
| 204 |
data = load_data()
|
| 205 |
all_products = data.get('products', [])
|
| 206 |
+
categories = sorted(data.get('categories', []))
|
| 207 |
is_authenticated = 'user' in session
|
| 208 |
|
| 209 |
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
|
|
|
| 239 |
body.dark-mode .theme-toggle:hover { color: #55a683; }
|
| 240 |
.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; }
|
| 241 |
body.dark-mode .store-address { background-color: #253f37; color: #b0c8c1; }
|
|
|
|
| 242 |
.filters-container { margin: 20px 0; display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
| 243 |
.search-container { margin: 20px 0; text-align: center; }
|
| 244 |
#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; }
|
|
|
|
| 249 |
body.dark-mode .category-filter { background-color: #253f37; border-color: #2c4a41; color: #97b7ae; }
|
| 250 |
.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); }
|
| 251 |
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); }
|
|
|
|
| 252 |
.products-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; padding: 10px; }
|
| 253 |
+
@media (min-width: 600px) { .products-grid { grid-template-columns: repeat(3, 1fr); } }
|
| 254 |
+
@media (min-width: 900px) { .products-grid { grid-template-columns: repeat(4, 1fr); } }
|
| 255 |
+
@media (min-width: 1200px) { .products-grid { grid-template-columns: repeat(5, 1fr); } }
|
| 256 |
+
.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; position: relative;}
|
| 257 |
body.dark-mode .product { background: #253f37; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #2c4a41; }
|
| 258 |
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
|
| 259 |
body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); }
|
|
|
|
| 271 |
.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; }
|
| 272 |
.product-button:hover { background-color: #164B41; box-shadow: 0 4px 15px rgba(22, 75, 65, 0.4); transform: translateY(-2px); }
|
| 273 |
.product-button i { margin-right: 5px; }
|
|
|
|
| 274 |
.add-to-cart { background-color: #38a169; }
|
| 275 |
.add-to-cart:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
|
| 276 |
#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; }
|
| 277 |
#cart-button .fa-shopping-cart { margin-right: 0; }
|
| 278 |
#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; }
|
|
|
|
| 279 |
.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; }
|
| 280 |
.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; }
|
| 281 |
body.dark-mode .modal-content { background: #253f37; color: #c8d8d3; }
|
|
|
|
| 308 |
.clear-cart:hover { background-color: #5e6e68; box-shadow: 0 4px 15px rgba(94, 110, 104, 0.4); }
|
| 309 |
.order-button { background-color: #38a169; }
|
| 310 |
.order-button:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
|
|
|
|
| 311 |
.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;}
|
| 312 |
.notification.show { opacity: 1;}
|
| 313 |
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #5e6e68; }
|
| 314 |
body.dark-mode .no-results-message { color: #8aa39a; }
|
| 315 |
.top-product-indicator { position: absolute; top: 8px; right: 8px; background-color: rgba(255, 215, 0, 0.8); color: #333; padding: 2px 6px; font-size: 0.7rem; border-radius: 4px; font-weight: bold; z-index: 10; backdrop-filter: blur(2px); }
|
|
|
|
| 316 |
|
| 317 |
</style>
|
| 318 |
</head>
|
|
|
|
| 349 |
<div class="products-grid" id="products-grid">
|
| 350 |
{% for product in products %}
|
| 351 |
<div class="product"
|
| 352 |
+
data-id="{{ loop.index0 }}"
|
| 353 |
data-name="{{ product['name']|lower }}"
|
| 354 |
data-description="{{ product.get('description', '')|lower }}"
|
| 355 |
data-category="{{ product.get('category', 'Без категории') }}">
|
|
|
|
| 437 |
|
| 438 |
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
|
| 439 |
<script>
|
| 440 |
+
let products = {{ products|tojson }};
|
| 441 |
const repoId = '{{ repo_id }}';
|
| 442 |
const currencyCode = '{{ currency_code }}';
|
| 443 |
const isAuthenticated = {{ is_authenticated|tojson }};
|
|
|
|
| 460 |
document.body.classList.add('dark-mode');
|
| 461 |
const icon = document.querySelector('.theme-toggle i');
|
| 462 |
if (icon) icon.classList.replace('fa-moon', 'fa-sun');
|
| 463 |
+
} else {
|
| 464 |
+
document.body.classList.remove('dark-mode');
|
| 465 |
+
const icon = document.querySelector('.theme-toggle i');
|
| 466 |
+
if (icon) icon.classList.replace('fa-sun', 'fa-moon');
|
| 467 |
}
|
| 468 |
}
|
| 469 |
|
|
|
|
| 565 |
const colorLabel = document.querySelector('label[for="colorSelect"]');
|
| 566 |
colorSelect.innerHTML = '';
|
| 567 |
|
| 568 |
+
const validColors = product.colors ? product.colors.filter(c => c && String(c).trim() !== "") : [];
|
| 569 |
|
| 570 |
if (validColors.length > 0) {
|
| 571 |
validColors.forEach(color => {
|
| 572 |
const option = document.createElement('option');
|
| 573 |
+
option.value = String(color).trim();
|
| 574 |
+
option.text = String(color).trim();
|
| 575 |
colorSelect.appendChild(option);
|
| 576 |
});
|
| 577 |
colorSelect.style.display = 'block';
|
|
|
|
| 590 |
}
|
| 591 |
|
| 592 |
function confirmAddToCart() {
|
| 593 |
+
if (selectedProductIndex === null || selectedProductIndex >= products.length) {
|
| 594 |
+
console.error("Invalid selectedProductIndex:", selectedProductIndex);
|
| 595 |
+
alert("Ошибка выбора товара.");
|
| 596 |
+
return;
|
| 597 |
+
}
|
| 598 |
|
| 599 |
const quantityInput = document.getElementById('quantityInput');
|
| 600 |
const quantity = parseInt(quantityInput.value);
|
|
|
|
| 664 |
cartTotalElement.textContent = '0.00';
|
| 665 |
} else {
|
| 666 |
cartContent.innerHTML = cart.map(item => {
|
| 667 |
+
const itemTotal = (item.price || 0) * (item.quantity || 0);
|
| 668 |
total += itemTotal;
|
| 669 |
const photoUrl = item.photo
|
| 670 |
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}`
|
|
|
|
| 676 |
<img src="${photoUrl}" alt="${item.name}">
|
| 677 |
<div class="cart-item-details">
|
| 678 |
<strong>${item.name}${colorText}</strong>
|
| 679 |
+
<p class="cart-item-price">${(item.price || 0).toFixed(2)} ${currencyCode} × ${item.quantity || 0}</p>
|
| 680 |
</div>
|
| 681 |
<span class="cart-item-total">${itemTotal.toFixed(2)} ${currencyCode}</span>
|
| 682 |
<button class="cart-item-remove" onclick="removeFromCart('${item.id}')" title="Удалить товар">×</button>
|
|
|
|
| 714 |
return;
|
| 715 |
}
|
| 716 |
let total = 0;
|
| 717 |
+
let orderText = "🛍️ *Новый Заказ от Soola Cosmetics* 🛍️\n";
|
| 718 |
+
orderText += "----------------------------------------\n";
|
| 719 |
+
orderText += "*Детали заказа:*\n";
|
| 720 |
+
orderText += "----------------------------------------\n";
|
| 721 |
cart.forEach((item, index) => {
|
| 722 |
+
const itemTotal = (item.price || 0) * (item.quantity || 0);
|
| 723 |
total += itemTotal;
|
| 724 |
const colorText = item.color !== 'N/A' ? ` (${item.color})` : '';
|
| 725 |
+
orderText += `${index + 1}. *${item.name}*${colorText}\n`;
|
| 726 |
+
orderText += ` Кол-во: ${item.quantity || 0}\n`;
|
| 727 |
+
orderText += ` Цена: ${(item.price || 0).toFixed(2)} ${currencyCode}\n`;
|
| 728 |
+
orderText += ` *Сумма: ${itemTotal.toFixed(2)} ${currencyCode}*\n\n`;
|
| 729 |
});
|
| 730 |
+
orderText += "----------------------------------------\n";
|
| 731 |
+
orderText += `*ИТОГО: ${total.toFixed(2)} ${currencyCode}*\n`;
|
| 732 |
+
orderText += "----------------------------------------\n\n";
|
| 733 |
|
| 734 |
if (userInfo && userInfo.login) {
|
| 735 |
+
orderText += "*Данные клиента:*\n";
|
| 736 |
+
orderText += `Имя: ${userInfo.first_name || ''} ${userInfo.last_name || ''}\n`;
|
| 737 |
+
orderText += `Логин: ${userInfo.login}\n`;
|
| 738 |
if (userInfo.phone) {
|
| 739 |
+
orderText += `Телефон: ${userInfo.phone}\n`;
|
| 740 |
}
|
| 741 |
+
orderText += `Страна: ${userInfo.country || 'Не указана'}\n`;
|
| 742 |
+
orderText += `Город: ${userInfo.city || 'Не указан'}\n`;
|
| 743 |
} else {
|
| 744 |
+
orderText += "*Клиент не авторизован*\n";
|
| 745 |
}
|
| 746 |
+
orderText += "----------------------------------------\n\n";
|
| 747 |
|
| 748 |
const now = new Date();
|
| 749 |
const dateTimeString = now.toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
| 750 |
+
orderText += `Дата заказа: ${dateTimeString}\n`;
|
| 751 |
orderText += `_Сформировано автоматически_`;
|
| 752 |
|
| 753 |
const whatsappNumber = "996997703090";
|
|
|
|
| 766 |
if (existingNoResults) existingNoResults.remove();
|
| 767 |
|
| 768 |
document.querySelectorAll('.products-grid .product').forEach(productElement => {
|
| 769 |
+
const name = productElement.getAttribute('data-name') || '';
|
| 770 |
+
const description = productElement.getAttribute('data-description') || '';
|
| 771 |
+
const category = productElement.getAttribute('data-category') || '';
|
| 772 |
|
| 773 |
const matchesSearch = !searchTerm || name.includes(searchTerm) || description.includes(searchTerm);
|
| 774 |
const matchesCategory = activeCategory === 'all' || category === activeCategory;
|
|
|
|
| 781 |
}
|
| 782 |
});
|
| 783 |
|
| 784 |
+
const hasProductsInitially = products && products.length > 0;
|
| 785 |
+
if (visibleProducts === 0 && hasProductsInitially) {
|
| 786 |
const p = document.createElement('p');
|
| 787 |
p.className = 'no-results-message';
|
| 788 |
p.textContent = 'По вашему запросу товары не найдены.';
|
| 789 |
grid.appendChild(p);
|
| 790 |
+
} else if (!hasProductsInitially && !grid.querySelector('.no-results-message')) {
|
| 791 |
const p = document.createElement('p');
|
| 792 |
p.className = 'no-results-message';
|
| 793 |
p.textContent = 'Товары пока не добавлены.';
|
| 794 |
grid.appendChild(p);
|
| 795 |
}
|
| 796 |
+
// Refresh products array from DOM in case it changed (e.g., admin edits) - less efficient but safer
|
| 797 |
+
// Or better: ensure products array is updated via admin actions or page reload
|
| 798 |
+
// For now, relying on initial load and page reloads after admin actions.
|
| 799 |
}
|
| 800 |
|
| 801 |
function setupFilters() {
|
|
|
|
| 811 |
filterProducts();
|
| 812 |
});
|
| 813 |
});
|
| 814 |
+
filterProducts();
|
| 815 |
}
|
| 816 |
|
| 817 |
function showNotification(message, duration = 3000) {
|
| 818 |
const placeholder = document.getElementById('notification-placeholder');
|
| 819 |
+
if (!placeholder) {
|
| 820 |
+
console.warn("Notification placeholder not found");
|
| 821 |
+
return;
|
| 822 |
+
}
|
| 823 |
|
| 824 |
const notification = document.createElement('div');
|
| 825 |
notification.className = 'notification';
|
|
|
|
| 836 |
|
| 837 |
document.addEventListener('DOMContentLoaded', () => {
|
| 838 |
applyInitialTheme();
|
| 839 |
+
// attemptAutoLogin(); // Removed as it causes reload issues, rely on session
|
| 840 |
updateCartButton();
|
| 841 |
setupFilters();
|
| 842 |
|
|
|
|
| 853 |
});
|
| 854 |
}
|
| 855 |
});
|
| 856 |
+
|
| 857 |
+
// Re-assign products if needed, especially if modified elsewhere
|
| 858 |
+
// products = {{ products|tojson }}; // Re-fetch potentially updated list
|
| 859 |
+
// Re-initialize filter if needed after potential updates
|
| 860 |
+
filterProducts();
|
| 861 |
});
|
| 862 |
|
| 863 |
</script>
|
|
|
|
| 880 |
def product_detail(index):
|
| 881 |
data = load_data()
|
| 882 |
all_products = data.get('products', [])
|
| 883 |
+
# Filter and sort again here to ensure consistency with the catalog view
|
| 884 |
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 885 |
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
| 886 |
|
| 887 |
is_authenticated = 'user' in session
|
| 888 |
try:
|
| 889 |
+
if not (0 <= index < len(products_sorted)):
|
| 890 |
+
raise IndexError("Index out of bounds for available products.")
|
| 891 |
product = products_sorted[index]
|
| 892 |
+
except IndexError as e:
|
| 893 |
+
logging.warning(f"Product detail access error: {e} (Index: {index})")
|
|
|
|
|
|
|
| 894 |
return "Товар не найден или отсутствует в наличии.", 404
|
| 895 |
|
| 896 |
detail_html = '''
|
|
|
|
| 930 |
{% endif %}
|
| 931 |
<p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\\n', '<br>')|safe }}</p>
|
| 932 |
{% set colors = product.get('colors', []) %}
|
| 933 |
+
{% set valid_colors = colors|select('string')|select('ne', '')|list %}
|
| 934 |
+
{% if valid_colors|length > 0 %}
|
| 935 |
+
<p><strong>Доступные цвета/варианты:</strong> {{ valid_colors|join(', ') }}</p>
|
| 936 |
{% endif %}
|
| 937 |
</div>
|
| 938 |
</div>
|
|
|
|
| 1007 |
'city': user_info.get('city', ''),
|
| 1008 |
'phone': user_info.get('phone', '')
|
| 1009 |
}
|
| 1010 |
+
logging.info(f"User {login} logged in successfully.")
|
| 1011 |
+
# No need for localStorage auto-login here, session handles it
|
| 1012 |
+
return redirect(url_for('catalog'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1013 |
else:
|
| 1014 |
+
logging.warning(f"Failed login attempt for user {login}.")
|
| 1015 |
error_message = "Неверный логин или пароль."
|
| 1016 |
return render_template_string(LOGIN_TEMPLATE, error=error_message), 401
|
| 1017 |
|
| 1018 |
return render_template_string(LOGIN_TEMPLATE, error=None)
|
| 1019 |
|
| 1020 |
+
# Removed auto_login route as it caused issues and session handles persistence
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1021 |
|
| 1022 |
@app.route('/logout')
|
| 1023 |
def logout():
|
|
|
|
| 1025 |
session.pop('user', None)
|
| 1026 |
session.pop('user_info', None)
|
| 1027 |
if logged_out_user:
|
| 1028 |
+
logging.info(f"User {logged_out_user} logged out.")
|
| 1029 |
+
# No need for localStorage interaction here
|
| 1030 |
+
return redirect(url_for('catalog'))
|
| 1031 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1032 |
|
| 1033 |
ADMIN_TEMPLATE = '''
|
| 1034 |
<!DOCTYPE html>
|
|
|
|
| 1067 |
.add-button:hover { background-color: #2f855a; }
|
| 1068 |
.item-list { display: grid; gap: 20px; }
|
| 1069 |
.item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #e1f0e9; }
|
| 1070 |
+
.item p { margin: 5px 0; font-size: 0.9rem; color: #44524c; word-break: break-word; }
|
| 1071 |
.item strong { color: #2d332f; }
|
| 1072 |
.item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; }
|
| 1073 |
.item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
|
|
|
| 1124 |
<button type="submit" class="button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить БД</button>
|
| 1125 |
</form>
|
| 1126 |
<form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно скачать данные с сервера? Это перезапишет ваши локальные файлы.');">
|
| 1127 |
+
<button type="submit" class="button download-hf-button" title="Скачать файлы (перезапишет локальные)"><i class="fas fa-download"></i> Скачать БД</button>
|
| 1128 |
</form>
|
| 1129 |
</div>
|
| 1130 |
<p style="font-size: 0.85rem; color: #5e6e68;">Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p>
|
|
|
|
| 1254 |
<button type="button" class="button add-color-btn" style="margin-top: 5px;" onclick="addColorInput('add-color-inputs')"><i class="fas fa-palette"></i> Добавить поле для цвета/варианта</button>
|
| 1255 |
<br>
|
| 1256 |
<div style="margin-top: 15px;">
|
| 1257 |
+
<input type="checkbox" id="add_in_stock" name="in_stock" value="true" checked>
|
| 1258 |
<label for="add_in_stock" class="inline-label">В наличии</label>
|
| 1259 |
</div>
|
| 1260 |
<div style="margin-top: 5px;">
|
| 1261 |
+
<input type="checkbox" id="add_is_top" name="is_top" value="true">
|
| 1262 |
<label for="add_is_top" class="inline-label">Топ товар (показывать наверху)</label>
|
| 1263 |
</div>
|
| 1264 |
<br>
|
|
|
|
| 1274 |
<div class="item">
|
| 1275 |
<div style="display: flex; gap: 15px; align-items: flex-start;">
|
| 1276 |
<div class="photo-preview" style="flex-shrink: 0;">
|
| 1277 |
+
{% if product.get('photos') and product['photos']|length > 0 %}
|
| 1278 |
<a href="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" target="_blank" title="Посмотреть первое фото">
|
| 1279 |
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" alt="Фото">
|
| 1280 |
</a>
|
|
|
|
| 1298 |
<p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 1299 |
<p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
|
| 1300 |
{% set colors = product.get('colors', []) %}
|
| 1301 |
+
{% set valid_colors = colors|select('string')|select('ne', '')|list %}
|
| 1302 |
+
<p><strong>Цвета/Вар-ты:</strong> {{ valid_colors|join(', ') if valid_colors|length > 0 else 'Нет' }}</p>
|
| 1303 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 1304 |
<p style="font-size: 0.8rem; color: #5e6e68;">(Всего фото: {{ product['photos']|length }})</p>
|
| 1305 |
{% endif %}
|
|
|
|
| 1345 |
{% endif %}
|
| 1346 |
<label>Цвета/Варианты:</label>
|
| 1347 |
<div id="edit-color-inputs-{{ loop.index0 }}">
|
| 1348 |
+
{% set current_colors = product.get('colors', [])|select('string')|select('ne', '')|list %}
|
| 1349 |
+
{% if current_colors|length > 0 %}
|
| 1350 |
{% for color in current_colors %}
|
|
|
|
| 1351 |
<div class="color-input-group">
|
| 1352 |
<input type="text" name="colors" value="{{ color }}">
|
| 1353 |
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1354 |
</div>
|
|
|
|
| 1355 |
{% endfor %}
|
| 1356 |
{% else %}
|
| 1357 |
<div class="color-input-group">
|
|
|
|
| 1363 |
<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>
|
| 1364 |
<br>
|
| 1365 |
<div style="margin-top: 15px;">
|
| 1366 |
+
<input type="checkbox" id="edit_in_stock_{{ loop.index0 }}" name="in_stock" value="true" {% if product.get('in_stock', True) %}checked{% endif %}>
|
| 1367 |
<label for="edit_in_stock_{{ loop.index0 }}" class="inline-label">В наличии</label>
|
| 1368 |
</div>
|
| 1369 |
<div style="margin-top: 5px;">
|
| 1370 |
+
<input type="checkbox" id="edit_is_top_{{ loop.index0 }}" name="is_top" value="true" {% if product.get('is_top', False) %}checked{% endif %}>
|
| 1371 |
<label for="edit_is_top_{{ loop.index0 }}" class="inline-label">Топ товар</label>
|
| 1372 |
</div>
|
| 1373 |
<br>
|
|
|
|
| 1413 |
const group = button.closest('.color-input-group');
|
| 1414 |
if (group) {
|
| 1415 |
const container = group.parentNode;
|
|
|
|
|
|
|
| 1416 |
group.remove();
|
| 1417 |
+
// If container is now empty, add a placeholder input back
|
| 1418 |
if (container && container.children.length === 0) {
|
| 1419 |
const placeholderGroup = document.createElement('div');
|
| 1420 |
placeholderGroup.className = 'color-input-group';
|
| 1421 |
placeholderGroup.innerHTML = `
|
| 1422 |
+
<input type="text" name="colors" placeholder="Цвет/вариант">
|
| 1423 |
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1424 |
`;
|
| 1425 |
container.appendChild(placeholderGroup);
|
| 1426 |
}
|
| 1427 |
} else {
|
| 1428 |
+
console.warn("Could not find parent .color-input-group for remove button");
|
| 1429 |
}
|
| 1430 |
}
|
| 1431 |
</script>
|
|
|
|
| 1435 |
|
| 1436 |
@app.route('/admin', methods=['GET', 'POST'])
|
| 1437 |
def admin():
|
| 1438 |
+
# Ensure only logged-in users (or specific admin users if implemented) can access
|
| 1439 |
+
# if 'user' not in session: # Basic check, enhance if roles needed
|
| 1440 |
+
# flash("Доступ запрещен. Пожалуйста, войдите.", "error")
|
| 1441 |
+
# return redirect(url_for('login'))
|
| 1442 |
+
|
| 1443 |
data = load_data()
|
|
|
|
|
|
|
| 1444 |
users = load_users()
|
| 1445 |
+
# Keep original product list with original indices for editing/deleting
|
| 1446 |
+
original_product_list = data.get('products', [])
|
| 1447 |
+
categories = sorted(data.get('categories', [])) # Keep categories sorted for display
|
| 1448 |
|
| 1449 |
if request.method == 'POST':
|
| 1450 |
action = request.form.get('action')
|
|
|
|
| 1453 |
try:
|
| 1454 |
if action == 'add_category':
|
| 1455 |
category_name = request.form.get('category_name', '').strip()
|
| 1456 |
+
if category_name and category_name not in data.get('categories', []):
|
| 1457 |
+
data.setdefault('categories', []).append(category_name)
|
| 1458 |
+
# No need to sort here, will be sorted on next load/display
|
| 1459 |
save_data(data)
|
| 1460 |
+
logging.info(f"Category '{category_name}' added.")
|
| 1461 |
flash(f"Категория '{category_name}' успешно добавлена.", 'success')
|
| 1462 |
elif not category_name:
|
| 1463 |
+
logging.warning("Attempt to add empty category.")
|
| 1464 |
flash("Название категории не может быть пустым.", 'error')
|
| 1465 |
else:
|
| 1466 |
+
logging.warning(f"Category '{category_name}' already exists.")
|
| 1467 |
flash(f"Категория '{category_name}' уже существует.", 'error')
|
| 1468 |
|
| 1469 |
elif action == 'delete_category':
|
| 1470 |
category_to_delete = request.form.get('category_name')
|
| 1471 |
+
current_categories = data.get('categories', [])
|
| 1472 |
+
if category_to_delete and category_to_delete in current_categories:
|
| 1473 |
+
current_categories.remove(category_to_delete)
|
| 1474 |
updated_count = 0
|
| 1475 |
+
current_products = data.get('products', [])
|
| 1476 |
+
for product in current_products:
|
| 1477 |
if product.get('category') == category_to_delete:
|
| 1478 |
product['category'] = 'Без категории'
|
| 1479 |
updated_count += 1
|
| 1480 |
save_data(data)
|
| 1481 |
+
logging.info(f"Category '{category_to_delete}' deleted. Updated products: {updated_count}.")
|
| 1482 |
flash(f"Категория '{category_to_delete}' удалена.", 'success')
|
| 1483 |
else:
|
| 1484 |
+
logging.warning(f"Attempt to delete non-existent or empty category: {category_to_delete}")
|
| 1485 |
flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error')
|
| 1486 |
|
|
|
|
| 1487 |
elif action == 'add_product':
|
| 1488 |
name = request.form.get('name', '').strip()
|
| 1489 |
price_str = request.form.get('price', '').replace(',', '.')
|
|
|
|
| 1491 |
category = request.form.get('category')
|
| 1492 |
photos_files = request.files.getlist('photos')
|
| 1493 |
colors = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1494 |
+
in_stock = request.form.get('in_stock') == 'true' # Checkbox value
|
| 1495 |
+
is_top = request.form.get('is_top') == 'true' # Checkbox value
|
|
|
|
| 1496 |
|
| 1497 |
if not name or not price_str:
|
| 1498 |
flash("Название и цена товара обязательны.", 'error')
|
|
|
|
| 1500 |
|
| 1501 |
try:
|
| 1502 |
price = round(float(price_str), 2)
|
| 1503 |
+
if price < 0: raise ValueError("Price cannot be negative")
|
| 1504 |
except ValueError:
|
| 1505 |
+
flash("Неверный формат цены или отрицательное значение.", 'error')
|
| 1506 |
return redirect(url_for('admin'))
|
| 1507 |
|
| 1508 |
photos_list = []
|
| 1509 |
+
if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
|
| 1510 |
uploads_dir = 'uploads_temp'
|
| 1511 |
os.makedirs(uploads_dir, exist_ok=True)
|
| 1512 |
api = HfApi()
|
| 1513 |
photo_limit = 10
|
| 1514 |
uploaded_count = 0
|
| 1515 |
for photo in photos_files:
|
| 1516 |
+
if not photo or not photo.filename: continue
|
| 1517 |
+
if uploaded_count >= photo_limit:
|
| 1518 |
+
logging.warning(f"Photo limit ({photo_limit}) reached, ignoring further files.")
|
| 1519 |
flash(f"Загружено только первые {photo_limit} фото.", "warning")
|
| 1520 |
break
|
| 1521 |
+
try:
|
| 1522 |
+
ext = os.path.splitext(photo.filename)[1].lower()
|
| 1523 |
+
if ext not in ['.png', '.jpg', '.jpeg', '.gif', '.webp']:
|
| 1524 |
+
logging.warning(f"Skipping non-image file: {photo.filename}")
|
| 1525 |
+
continue
|
| 1526 |
+
safe_base = secure_filename(name.replace(' ','_') or 'product')
|
| 1527 |
+
photo_filename = f"{safe_base}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
|
| 1528 |
+
temp_path = os.path.join(uploads_dir, photo_filename)
|
| 1529 |
+
photo.save(temp_path)
|
| 1530 |
+
logging.info(f"Uploading photo {photo_filename} to HF for product {name}...")
|
| 1531 |
+
api.upload_file(
|
| 1532 |
+
path_or_fileobj=temp_path,
|
| 1533 |
+
path_in_repo=f"photos/{photo_filename}",
|
| 1534 |
+
repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE,
|
| 1535 |
+
commit_message=f"Add photo for product {name}"
|
| 1536 |
+
)
|
| 1537 |
+
photos_list.append(photo_filename)
|
| 1538 |
+
logging.info(f"Photo {photo_filename} uploaded successfully.")
|
| 1539 |
+
os.remove(temp_path)
|
| 1540 |
+
uploaded_count += 1
|
| 1541 |
+
except Exception as e:
|
| 1542 |
+
logging.error(f"Error uploading photo {photo.filename} to HF: {e}", exc_info=True)
|
| 1543 |
+
flash(f"Ошибка при загрузке фото {photo.filename}.", 'error')
|
| 1544 |
+
# Optionally remove temp file if upload failed
|
| 1545 |
+
if os.path.exists(temp_path): os.remove(temp_path)
|
| 1546 |
+
try: # Cleanup temp dir
|
| 1547 |
+
if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
|
| 1548 |
+
os.rmdir(uploads_dir)
|
| 1549 |
except OSError as e:
|
| 1550 |
+
logging.warning(f"Could not remove temp upload dir {uploads_dir}: {e}")
|
|
|
|
| 1551 |
|
| 1552 |
new_product = {
|
| 1553 |
'name': name, 'price': price, 'description': description,
|
| 1554 |
+
'category': category if category in data.get('categories', []) else 'Без категории',
|
| 1555 |
'photos': photos_list, 'colors': colors,
|
| 1556 |
'in_stock': in_stock, 'is_top': is_top
|
| 1557 |
}
|
| 1558 |
+
data.setdefault('products', []).append(new_product)
|
|
|
|
| 1559 |
save_data(data)
|
| 1560 |
+
logging.info(f"Product '{name}' added.")
|
| 1561 |
flash(f"Товар '{name}' успешно добавлен.", 'success')
|
| 1562 |
|
| 1563 |
elif action == 'edit_product':
|
|
|
|
| 1568 |
|
| 1569 |
try:
|
| 1570 |
index = int(index_str)
|
| 1571 |
+
# Use the original list loaded at the start of the request
|
| 1572 |
+
if not (0 <= index < len(original_product_list)): raise IndexError("Index out of bounds")
|
| 1573 |
+
product_to_edit = original_product_list[index] # Get ref to the dict in the list
|
|
|
|
|
|
|
| 1574 |
original_name = product_to_edit.get('name', 'N/A')
|
|
|
|
| 1575 |
except (ValueError, IndexError):
|
| 1576 |
flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error')
|
| 1577 |
return redirect(url_for('admin'))
|
| 1578 |
|
| 1579 |
+
# Update fields in the dictionary directly
|
| 1580 |
+
product_to_edit['name'] = request.form.get('name', product_to_edit.get('name', '')).strip()
|
| 1581 |
+
price_str = request.form.get('price', str(product_to_edit.get('price', 0))).replace(',', '.')
|
| 1582 |
+
product_to_edit['description'] = request.form.get('description', product_to_edit.get('description', '')).strip()
|
| 1583 |
category = request.form.get('category')
|
| 1584 |
+
product_to_edit['category'] = category if category in data.get('categories', []) else 'Без категории'
|
| 1585 |
product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()]
|
| 1586 |
+
product_to_edit['in_stock'] = request.form.get('in_stock') == 'true'
|
| 1587 |
+
product_to_edit['is_top'] = request.form.get('is_top') == 'true'
|
|
|
|
| 1588 |
|
| 1589 |
try:
|
| 1590 |
price = round(float(price_str), 2)
|
| 1591 |
+
if price < 0: raise ValueError("Price cannot be negative")
|
| 1592 |
product_to_edit['price'] = price
|
| 1593 |
except ValueError:
|
| 1594 |
+
logging.warning(f"Invalid price format '{price_str}' during edit for {original_name}. Price not changed.")
|
| 1595 |
+
flash(f"Неверный формат цены для товара '{product_to_edit['name']}'. Цена не изменена.", 'warning')
|
| 1596 |
|
| 1597 |
photos_files = request.files.getlist('photos')
|
| 1598 |
+
# Check if any *new* files were actually selected
|
| 1599 |
+
if photos_files and any(f and f.filename for f in photos_files) and HF_TOKEN_WRITE:
|
| 1600 |
uploads_dir = 'uploads_temp'
|
| 1601 |
os.makedirs(uploads_dir, exist_ok=True)
|
| 1602 |
api = HfApi()
|
| 1603 |
new_photos_list = []
|
| 1604 |
photo_limit = 10
|
| 1605 |
uploaded_count = 0
|
| 1606 |
+
logging.info(f"Uploading new photos for product {product_to_edit['name']}...")
|
| 1607 |
for photo in photos_files:
|
| 1608 |
+
if not photo or not photo.filename: continue
|
| 1609 |
+
if uploaded_count >= photo_limit:
|
| 1610 |
+
logging.warning(f"Photo limit ({photo_limit}) reached, ignoring further files.")
|
| 1611 |
flash(f"Загружено только первые {photo_limit} фото.", "warning")
|
| 1612 |
break
|
| 1613 |
+
try:
|
| 1614 |
+
ext = os.path.splitext(photo.filename)[1].lower()
|
| 1615 |
+
if ext not in ['.png', '.jpg', '.jpeg', '.gif', '.webp']:
|
| 1616 |
+
logging.warning(f"Skipping non-image file: {photo.filename}")
|
| 1617 |
+
continue
|
| 1618 |
+
safe_base = secure_filename(product_to_edit['name'].replace(' ','_') or 'product')
|
| 1619 |
+
photo_filename = f"{safe_base}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
|
| 1620 |
+
temp_path = os.path.join(uploads_dir, photo_filename)
|
| 1621 |
+
photo.save(temp_path)
|
| 1622 |
+
logging.info(f"Uploading new photo {photo_filename} to HF...")
|
| 1623 |
+
api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}",
|
| 1624 |
+
repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE,
|
| 1625 |
+
commit_message=f"Update photo for product {product_to_edit['name']}")
|
| 1626 |
+
new_photos_list.append(photo_filename)
|
| 1627 |
+
logging.info(f"New photo {photo_filename} uploaded successfully.")
|
| 1628 |
+
os.remove(temp_path)
|
| 1629 |
+
uploaded_count += 1
|
| 1630 |
+
except Exception as e:
|
| 1631 |
+
logging.error(f"Error uploading new photo {photo.filename}: {e}", exc_info=True)
|
| 1632 |
+
flash(f"Ошибка при загрузке нового фото {photo.filename}.", 'error')
|
| 1633 |
+
if os.path.exists(temp_path): os.remove(temp_path)
|
| 1634 |
+
try: # Cleanup temp dir
|
| 1635 |
+
if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
|
| 1636 |
os.rmdir(uploads_dir)
|
| 1637 |
except OSError as e:
|
| 1638 |
+
logging.warning(f"Could not remove temp upload dir {uploads_dir}: {e}")
|
| 1639 |
|
| 1640 |
+
# Only replace photos if new ones were successfully uploaded
|
| 1641 |
if new_photos_list:
|
| 1642 |
+
logging.info(f"Replacing photos for product {product_to_edit['name']}.")
|
| 1643 |
old_photos = product_to_edit.get('photos', [])
|
| 1644 |
if old_photos:
|
| 1645 |
+
logging.info(f"Attempting to delete old photos from HF: {old_photos}")
|
| 1646 |
try:
|
| 1647 |
+
# Use ignore_patterns for broader matching if needed, but paths_in_repo is safer
|
| 1648 |
api.delete_files(
|
| 1649 |
repo_id=REPO_ID,
|
| 1650 |
+
paths_in_repo=[f"photos/{p}" for p in old_photos if p], # Ensure no empty strings
|
| 1651 |
repo_type="dataset",
|
| 1652 |
token=HF_TOKEN_WRITE,
|
| 1653 |
+
commit_message=f"Delete old photos for product {product_to_edit['name']}",
|
| 1654 |
+
missing_ok=True # Don't fail if a photo was already deleted somehow
|
| 1655 |
)
|
| 1656 |
+
logging.info(f"Old photos deletion command sent for {product_to_edit['name']}.")
|
| 1657 |
except Exception as e:
|
| 1658 |
+
logging.error(f"Error deleting old photos {old_photos} from HF: {e}", exc_info=True)
|
| 1659 |
flash("Не удалось удалить старые фотографии с сервера.", "warning")
|
| 1660 |
product_to_edit['photos'] = new_photos_list
|
| 1661 |
flash("Фотографии товара успешно обновлены.", "success")
|
| 1662 |
+
elif uploaded_count == 0 and any(f and f.filename for f in photos_files):
|
| 1663 |
+
# Files were selected, but none uploaded (e.g., all invalid format or upload errors)
|
| 1664 |
+
flash("Не удалось загрузить ни одну из выбранн��х новых фотографий.", "error")
|
| 1665 |
+
# If no new files were selected, photos remain unchanged.
|
| 1666 |
|
| 1667 |
+
# Now save the entire modified data structure
|
| 1668 |
save_data(data)
|
| 1669 |
+
logging.info(f"Product '{original_name}' (index {index}) updated to '{product_to_edit['name']}'.")
|
| 1670 |
flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success')
|
| 1671 |
|
|
|
|
| 1672 |
elif action == 'delete_product':
|
| 1673 |
index_str = request.form.get('index')
|
| 1674 |
if index_str is None:
|
|
|
|
| 1676 |
return redirect(url_for('admin'))
|
| 1677 |
try:
|
| 1678 |
index = int(index_str)
|
| 1679 |
+
# Use the original list loaded at the start of the request
|
| 1680 |
+
if not (0 <= index < len(original_product_list)): raise IndexError("Index out of bounds")
|
| 1681 |
+
# Remove from the main data structure
|
| 1682 |
+
deleted_product = data.get('products', []).pop(index)
|
| 1683 |
product_name = deleted_product.get('name', 'N/A')
|
| 1684 |
|
| 1685 |
photos_to_delete = deleted_product.get('photos', [])
|
| 1686 |
if photos_to_delete and HF_TOKEN_WRITE:
|
| 1687 |
+
logging.info(f"Attempting to delete photos for deleted product '{product_name}' from HF: {photos_to_delete}")
|
| 1688 |
try:
|
| 1689 |
api = HfApi()
|
| 1690 |
api.delete_files(
|
| 1691 |
repo_id=REPO_ID,
|
| 1692 |
+
paths_in_repo=[f"photos/{p}" for p in photos_to_delete if p],
|
| 1693 |
repo_type="dataset",
|
| 1694 |
token=HF_TOKEN_WRITE,
|
| 1695 |
+
commit_message=f"Delete photos for deleted product {product_name}",
|
| 1696 |
+
missing_ok=True
|
| 1697 |
)
|
| 1698 |
+
logging.info(f"Photos deletion command sent for product '{product_name}'.")
|
| 1699 |
except Exception as e:
|
| 1700 |
+
logging.error(f"Error deleting photos {photos_to_delete} for product '{product_name}' from HF: {e}", exc_info=True)
|
| 1701 |
flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning")
|
| 1702 |
|
| 1703 |
save_data(data)
|
| 1704 |
+
logging.info(f"Product '{product_name}' (original index {index}) deleted.")
|
| 1705 |
flash(f"Товар '{product_name}' удален.", 'success')
|
| 1706 |
except (ValueError, IndexError):
|
| 1707 |
flash(f"Ошибка удаления: неверный индекс товара '{index_str}'.", 'error')
|
| 1708 |
+
except Exception as e:
|
| 1709 |
+
logging.error(f"Error during product deletion: {e}", exc_info=True)
|
| 1710 |
+
flash(f"Произошла ошибка при удалении товара.", 'error')
|
| 1711 |
|
| 1712 |
elif action == 'add_user':
|
| 1713 |
login = request.form.get('login', '').strip()
|
| 1714 |
+
password = request.form.get('password') # Keep password as is, no stripping
|
| 1715 |
first_name = request.form.get('first_name', '').strip()
|
| 1716 |
last_name = request.form.get('last_name', '').strip()
|
| 1717 |
phone = request.form.get('phone', '').strip()
|
|
|
|
| 1726 |
return redirect(url_for('admin'))
|
| 1727 |
|
| 1728 |
users[login] = {
|
| 1729 |
+
'password': password, # Store password as provided
|
| 1730 |
'first_name': first_name, 'last_name': last_name,
|
| 1731 |
'phone': phone,
|
| 1732 |
'country': country, 'city': city
|
| 1733 |
}
|
| 1734 |
save_users(users)
|
| 1735 |
+
logging.info(f"User '{login}' added.")
|
| 1736 |
flash(f"Пользователь '{login}' успешно добавлен.", 'success')
|
| 1737 |
|
| 1738 |
elif action == 'delete_user':
|
|
|
|
| 1740 |
if login_to_delete and login_to_delete in users:
|
| 1741 |
del users[login_to_delete]
|
| 1742 |
save_users(users)
|
| 1743 |
+
logging.info(f"User '{login_to_delete}' deleted.")
|
| 1744 |
flash(f"Пользователь '{login_to_delete}' удален.", 'success')
|
| 1745 |
else:
|
| 1746 |
+
logging.warning(f"Attempt to delete non-existent or empty user: {login_to_delete}")
|
| 1747 |
flash(f"Не удалось удалить пользователя '{login_to_delete}'.", 'error')
|
| 1748 |
|
| 1749 |
else:
|
| 1750 |
+
logging.warning(f"Received unknown admin action: {action}")
|
| 1751 |
flash(f"Неизвестное действие: {action}", 'warning')
|
| 1752 |
|
| 1753 |
+
# Redirect after POST to prevent form resubmission on refresh
|
| 1754 |
return redirect(url_for('admin'))
|
| 1755 |
|
| 1756 |
except Exception as e:
|
| 1757 |
+
logging.error(f"Error processing admin action '{action}': {e}", exc_info=True)
|
| 1758 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1759 |
+
# Redirect even on error to avoid broken state
|
| 1760 |
return redirect(url_for('admin'))
|
| 1761 |
|
| 1762 |
+
# GET request: Render the template
|
| 1763 |
+
# Pass the original list to preserve indices for edit/delete forms
|
| 1764 |
+
# Pass sorted categories and users for display
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1765 |
sorted_users = dict(sorted(users.items()))
|
| 1766 |
|
| 1767 |
return render_template_string(
|
| 1768 |
ADMIN_TEMPLATE,
|
| 1769 |
+
products=original_product_list,
|
| 1770 |
categories=categories,
|
| 1771 |
users=sorted_users,
|
| 1772 |
repo_id=REPO_ID,
|
|
|
|
| 1775 |
|
| 1776 |
@app.route('/force_upload', methods=['POST'])
|
| 1777 |
def force_upload():
|
| 1778 |
+
# Add access control if needed
|
| 1779 |
+
logging.info("Forcing upload to Hugging Face...")
|
| 1780 |
try:
|
| 1781 |
upload_db_to_hf()
|
| 1782 |
flash("Данные успешно загружены на Hugging Face.", 'success')
|
| 1783 |
except Exception as e:
|
| 1784 |
+
logging.error(f"Error during forced upload: {e}", exc_info=True)
|
| 1785 |
flash(f"Ошибка при загрузке на Hugging Face: {e}", 'error')
|
| 1786 |
return redirect(url_for('admin'))
|
| 1787 |
|
| 1788 |
@app.route('/force_download', methods=['POST'])
|
| 1789 |
def force_download():
|
| 1790 |
+
# Add access control if needed
|
| 1791 |
+
logging.info("Forcing download from Hugging Face...")
|
| 1792 |
try:
|
| 1793 |
+
if download_db_from_hf():
|
| 1794 |
+
flash("Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.", 'success')
|
| 1795 |
+
# Reload data might be needed if the app holds state, but here it reloads on next request
|
| 1796 |
+
else:
|
| 1797 |
+
flash("Скачивание данных с Hugging Face завершилось с ошибками. Проверьте логи.", 'warning')
|
| 1798 |
+
|
| 1799 |
except Exception as e:
|
| 1800 |
+
logging.error(f"Error during forced download: {e}", exc_info=True)
|
| 1801 |
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
|
| 1802 |
return redirect(url_for('admin'))
|
| 1803 |
|
| 1804 |
|
| 1805 |
if __name__ == '__main__':
|
| 1806 |
+
# Initial load on startup
|
| 1807 |
load_data()
|
| 1808 |
load_users()
|
| 1809 |
|
| 1810 |
+
# Start backup thread only if write token exists
|
| 1811 |
if HF_TOKEN_WRITE:
|
| 1812 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
| 1813 |
backup_thread.start()
|
| 1814 |
+
logging.info("Periodic backup thread started.")
|
| 1815 |
else:
|
| 1816 |
+
logging.warning("HF_TOKEN not set, periodic backup thread will NOT run.")
|
| 1817 |
|
| 1818 |
+
# Run the Flask app
|
| 1819 |
port = int(os.environ.get('PORT', 7860))
|
| 1820 |
+
logging.info(f"Starting Flask app on host 0.0.0.0 port {port}")
|
| 1821 |
+
# Use Waitress or Gunicorn in production instead of development server
|
| 1822 |
app.run(debug=False, host='0.0.0.0', port=port)
|
| 1823 |
|
| 1824 |
+
|