Update app.py
Browse files
app.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
|
|
| 1 |
|
| 2 |
-
from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash
|
| 3 |
import json
|
| 4 |
import os
|
| 5 |
import logging
|
|
@@ -11,6 +12,7 @@ from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
|
|
| 11 |
from werkzeug.utils import secure_filename
|
| 12 |
from dotenv import load_dotenv
|
| 13 |
import requests # Added for more specific network error handling
|
|
|
|
| 14 |
|
| 15 |
load_dotenv()
|
| 16 |
|
|
@@ -21,7 +23,7 @@ USERS_FILE = 'users_soola.json'
|
|
| 21 |
|
| 22 |
SYNC_FILES = [DATA_FILE, USERS_FILE]
|
| 23 |
|
| 24 |
-
REPO_ID = "Kgshop/Soola"
|
| 25 |
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
|
| 26 |
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
| 27 |
|
|
@@ -35,6 +37,8 @@ DOWNLOAD_DELAY = 5 # seconds
|
|
| 35 |
|
| 36 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 37 |
|
|
|
|
|
|
|
| 38 |
def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
|
| 39 |
if not HF_TOKEN_READ and not HF_TOKEN_WRITE: # Allow read with write token too
|
| 40 |
logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
|
|
@@ -70,6 +74,19 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
|
|
| 70 |
except HfHubHTTPError as e:
|
| 71 |
if e.response.status_code == 404:
|
| 72 |
logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
success = False # Mark as failed for this specific file, but don't stop others
|
| 74 |
break # No point retrying a 404
|
| 75 |
else:
|
|
@@ -89,8 +106,48 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN
|
|
| 89 |
logging.info(f"Download process finished. Overall success: {all_successful}")
|
| 90 |
return all_successful
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def load_data():
|
| 93 |
-
default_data = {'products': [], 'categories': []}
|
| 94 |
try:
|
| 95 |
with open(DATA_FILE, 'r', encoding='utf-8') as file:
|
| 96 |
data = json.load(file)
|
|
@@ -100,6 +157,7 @@ def load_data():
|
|
| 100 |
raise FileNotFoundError # Treat as missing to trigger download
|
| 101 |
if 'products' not in data: data['products'] = []
|
| 102 |
if 'categories' not in data: data['categories'] = []
|
|
|
|
| 103 |
return data
|
| 104 |
except FileNotFoundError:
|
| 105 |
logging.warning(f"Local file {DATA_FILE} not found. Attempting download from HF.")
|
|
@@ -118,6 +176,7 @@ def load_data():
|
|
| 118 |
return default_data
|
| 119 |
if 'products' not in data: data['products'] = []
|
| 120 |
if 'categories' not in data: data['categories'] = []
|
|
|
|
| 121 |
return data
|
| 122 |
except FileNotFoundError:
|
| 123 |
logging.error(f"File {DATA_FILE} still not found even after download reported success. Using default.")
|
|
@@ -130,6 +189,14 @@ def load_data():
|
|
| 130 |
return default_data
|
| 131 |
else:
|
| 132 |
logging.error(f"Failed to download {DATA_FILE} from HF after retries. Using empty default data structure.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
return default_data # Return empty structure in memory
|
| 134 |
|
| 135 |
def save_data(data):
|
|
@@ -140,6 +207,7 @@ def save_data(data):
|
|
| 140 |
return
|
| 141 |
if 'products' not in data: data['products'] = []
|
| 142 |
if 'categories' not in data: data['categories'] = []
|
|
|
|
| 143 |
|
| 144 |
with open(DATA_FILE, 'w', encoding='utf-8') as file:
|
| 145 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
|
@@ -179,6 +247,14 @@ def load_users():
|
|
| 179 |
return default_users
|
| 180 |
else:
|
| 181 |
logging.error(f"Failed to download {USERS_FILE} from HF after retries. Using empty default user structure.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
return default_users # Return empty structure in memory
|
| 183 |
|
| 184 |
def save_users(users):
|
|
@@ -193,55 +269,9 @@ def save_users(users):
|
|
| 193 |
except Exception as e:
|
| 194 |
logging.error(f"Error saving user data to {USERS_FILE}: {e}", exc_info=True)
|
| 195 |
|
| 196 |
-
|
| 197 |
-
if not HF_TOKEN_WRITE:
|
| 198 |
-
logging.warning("HF_TOKEN (for writing) not set. Skipping upload to Hugging Face.")
|
| 199 |
-
return
|
| 200 |
-
try:
|
| 201 |
-
api = HfApi()
|
| 202 |
-
files_to_upload = [specific_file] if specific_file else SYNC_FILES
|
| 203 |
-
logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
|
| 204 |
-
|
| 205 |
-
for file_name in files_to_upload:
|
| 206 |
-
if os.path.exists(file_name):
|
| 207 |
-
try:
|
| 208 |
-
api.upload_file(
|
| 209 |
-
path_or_fileobj=file_name,
|
| 210 |
-
path_in_repo=file_name,
|
| 211 |
-
repo_id=REPO_ID,
|
| 212 |
-
repo_type="dataset",
|
| 213 |
-
token=HF_TOKEN_WRITE,
|
| 214 |
-
commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
| 215 |
-
)
|
| 216 |
-
logging.info(f"File {file_name} successfully uploaded to Hugging Face.")
|
| 217 |
-
except Exception as e:
|
| 218 |
-
logging.error(f"Error uploading file {file_name} to Hugging Face: {e}")
|
| 219 |
-
else:
|
| 220 |
-
logging.warning(f"File {file_name} not found locally, skipping upload.")
|
| 221 |
-
logging.info("Finished uploading files to HF.")
|
| 222 |
-
except Exception as e:
|
| 223 |
-
logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
|
| 224 |
-
|
| 225 |
-
def periodic_backup():
|
| 226 |
-
backup_interval = 1800
|
| 227 |
-
logging.info(f"Setting up periodic backup every {backup_interval} seconds.")
|
| 228 |
-
while True:
|
| 229 |
-
time.sleep(backup_interval)
|
| 230 |
-
logging.info("Starting periodic backup...")
|
| 231 |
-
upload_db_to_hf()
|
| 232 |
-
logging.info("Periodic backup finished.")
|
| 233 |
-
|
| 234 |
-
@app.route('/')
|
| 235 |
-
def catalog():
|
| 236 |
-
data = load_data()
|
| 237 |
-
all_products = data.get('products', [])
|
| 238 |
-
categories = data.get('categories', [])
|
| 239 |
-
is_authenticated = 'user' in session
|
| 240 |
-
|
| 241 |
-
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 242 |
-
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
| 243 |
|
| 244 |
-
|
| 245 |
<!DOCTYPE html>
|
| 246 |
<html lang="ru">
|
| 247 |
<head>
|
|
@@ -281,14 +311,17 @@ def catalog():
|
|
| 281 |
body.dark-mode .category-filter { background-color: #253f37; border-color: #2c4a41; color: #97b7ae; }
|
| 282 |
.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); }
|
| 283 |
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); }
|
| 284 |
-
.products-grid { display: grid; grid-template-columns: repeat(
|
|
|
|
|
|
|
|
|
|
| 285 |
.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;}
|
| 286 |
body.dark-mode .product { background: #253f37; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #2c4a41; }
|
| 287 |
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
|
| 288 |
body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); }
|
| 289 |
.product-image { width: 100%; aspect-ratio: 1 / 1; background-color: #fff; border-radius: 10px 10px 0 0; overflow: hidden; display: flex; justify-content: center; align-items: center; margin-bottom: 0; }
|
| 290 |
.product-image img { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.3s ease; }
|
| 291 |
-
|
| 292 |
.product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
|
| 293 |
.product h2 { font-size: 1.1rem; font-weight: 600; margin: 0 0 8px 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #2d332f; }
|
| 294 |
body.dark-mode .product h2 { color: #c8d8d3; }
|
|
@@ -320,10 +353,10 @@ def catalog():
|
|
| 320 |
.cart-item:last-child { border-bottom: none; }
|
| 321 |
.cart-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: #fff; padding: 5px; grid-column: 1; }
|
| 322 |
.cart-item-details { grid-column: 2; }
|
| 323 |
-
.cart-item-details strong { display: block; margin-bottom: 5px; }
|
| 324 |
.cart-item-price { font-size: 0.9rem; color: #44524c; }
|
| 325 |
body.dark-mode .cart-item-price { color: #8aa39a; }
|
| 326 |
-
.cart-item-total { font-weight: bold; text-align: right; grid-column: 3; }
|
| 327 |
.cart-item-remove { grid-column: 4; background:none; border:none; color:#f56565; cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; }
|
| 328 |
.cart-item-remove:hover { color: #c53030; }
|
| 329 |
.quantity-input, .color-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #d1e7dd; border-radius: 8px; font-size: 1rem; margin: 10px 0; box-sizing: border-box; }
|
|
@@ -335,8 +368,8 @@ def catalog():
|
|
| 335 |
.cart-actions .product-button { width: auto; flex-grow: 1; }
|
| 336 |
.clear-cart { background-color: #7a8d85; }
|
| 337 |
.clear-cart:hover { background-color: #5e6e68; box-shadow: 0 4px 15px rgba(94, 110, 104, 0.4); }
|
| 338 |
-
.order-button { background-color: #38a169; }
|
| 339 |
-
.order-button:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
|
| 340 |
.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;}
|
| 341 |
.notification.show { opacity: 1;}
|
| 342 |
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #5e6e68; }
|
|
@@ -449,8 +482,8 @@ def catalog():
|
|
| 449 |
<button class="product-button clear-cart" onclick="clearCart()">
|
| 450 |
<i class="fas fa-trash"></i> Очистить корзину
|
| 451 |
</button>
|
| 452 |
-
<button class="product-button order-button" onclick="
|
| 453 |
-
<i class="
|
| 454 |
</button>
|
| 455 |
</div>
|
| 456 |
</div>
|
|
@@ -574,7 +607,7 @@ def catalog():
|
|
| 574 |
function openQuantityModal(index) {
|
| 575 |
if (!isAuthenticated) {
|
| 576 |
alert('Пожалуйста, войдите в систему, чтобы добавить товар в корзину.');
|
| 577 |
-
window.location.href = '
|
| 578 |
return;
|
| 579 |
}
|
| 580 |
selectedProductIndex = index;
|
|
@@ -633,14 +666,14 @@ def catalog():
|
|
| 633 |
return;
|
| 634 |
}
|
| 635 |
|
| 636 |
-
const cartItemId = `${product.name}-${color}`;
|
| 637 |
const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
|
| 638 |
|
| 639 |
if (existingItemIndex > -1) {
|
| 640 |
cart[existingItemIndex].quantity += quantity;
|
| 641 |
} else {
|
| 642 |
cart.push({
|
| 643 |
-
id: cartItemId,
|
| 644 |
name: product.name,
|
| 645 |
price: product.price,
|
| 646 |
photo: product.photos && product.photos.length > 0 ? product.photos[0] : null,
|
|
@@ -715,7 +748,7 @@ def catalog():
|
|
| 715 |
function removeFromCart(itemId) {
|
| 716 |
cart = cart.filter(item => item.id !== itemId);
|
| 717 |
localStorage.setItem('soolaCart', JSON.stringify(cart));
|
| 718 |
-
openCartModal();
|
| 719 |
updateCartButton();
|
| 720 |
}
|
| 721 |
|
|
@@ -723,56 +756,55 @@ def catalog():
|
|
| 723 |
if (confirm("Вы уверены, что хотите очистить корзину?")) {
|
| 724 |
cart = [];
|
| 725 |
localStorage.removeItem('soolaCart');
|
| 726 |
-
openCartModal();
|
| 727 |
updateCartButton();
|
| 728 |
}
|
| 729 |
}
|
| 730 |
|
| 731 |
-
function
|
| 732 |
if (cart.length === 0) {
|
| 733 |
-
alert("Корзина пуста! Добавьте товары перед
|
| 734 |
return;
|
| 735 |
}
|
| 736 |
-
let total = 0;
|
| 737 |
-
let orderText = "🛍️ *Новый Заказ от Soola Cosmetics* 🛍️%0A";
|
| 738 |
-
orderText += "----------------------------------------%0A";
|
| 739 |
-
orderText += "*Детали заказа:*%0A";
|
| 740 |
-
orderText += "----------------------------------------%0A";
|
| 741 |
-
cart.forEach((item, index) => {
|
| 742 |
-
const itemTotal = item.price * item.quantity;
|
| 743 |
-
total += itemTotal;
|
| 744 |
-
const colorText = item.color !== 'N/A' ? ` (${item.color})` : '';
|
| 745 |
-
orderText += `${index + 1}. *${item.name}*${colorText}%0A`;
|
| 746 |
-
orderText += ` Кол-во: ${item.quantity}%0A`;
|
| 747 |
-
orderText += ` Цена: ${item.price.toFixed(2)} ${currencyCode}%0A`;
|
| 748 |
-
orderText += ` *Сумма: ${itemTotal.toFixed(2)} ${currencyCode}*%0A%0A`;
|
| 749 |
-
});
|
| 750 |
-
orderText += "----------------------------------------%0A";
|
| 751 |
-
orderText += `*ИТОГО: ${total.toFixed(2)} ${currencyCode}*%0A`;
|
| 752 |
-
orderText += "----------------------------------------%0A";
|
| 753 |
-
|
| 754 |
-
if (userInfo && userInfo.login) {
|
| 755 |
-
orderText += "*Данные клиента:*%0A";
|
| 756 |
-
orderText += `Имя: ${userInfo.first_name || ''} ${userInfo.last_name || ''}%0A`;
|
| 757 |
-
orderText += `Логин: ${userInfo.login}%0A`;
|
| 758 |
-
if (userInfo.phone) {
|
| 759 |
-
orderText += `Телефон: ${userInfo.phone}%0A`;
|
| 760 |
-
}
|
| 761 |
-
orderText += `Страна: ${userInfo.country || 'Не указана'}%0A`;
|
| 762 |
-
orderText += `Город: ${userInfo.city || 'Не указан'}%0A`;
|
| 763 |
-
} else {
|
| 764 |
-
orderText += "*Клиент не авторизован*%0A";
|
| 765 |
-
}
|
| 766 |
-
orderText += "----------------------------------------%0A";
|
| 767 |
-
|
| 768 |
-
const now = new Date();
|
| 769 |
-
const dateTimeString = now.toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
| 770 |
-
orderText += `Дата заказа: ${dateTimeString}%0A`;
|
| 771 |
-
orderText += `_Сформировано автоматически_`;
|
| 772 |
|
| 773 |
-
const
|
| 774 |
-
|
| 775 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 776 |
}
|
| 777 |
|
| 778 |
|
|
@@ -828,23 +860,39 @@ def catalog():
|
|
| 828 |
filterProducts();
|
| 829 |
});
|
| 830 |
});
|
| 831 |
-
filterProducts();
|
| 832 |
}
|
| 833 |
|
| 834 |
function showNotification(message, duration = 3000) {
|
| 835 |
const placeholder = document.getElementById('notification-placeholder');
|
| 836 |
-
if (!placeholder)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 837 |
|
| 838 |
const notification = document.createElement('div');
|
| 839 |
notification.className = 'notification';
|
| 840 |
notification.textContent = message;
|
| 841 |
placeholder.appendChild(notification);
|
| 842 |
|
| 843 |
-
|
|
|
|
|
|
|
|
|
|
| 844 |
|
| 845 |
setTimeout(() => {
|
| 846 |
notification.classList.remove('show');
|
| 847 |
-
|
|
|
|
| 848 |
}, duration);
|
| 849 |
}
|
| 850 |
|
|
@@ -854,12 +902,14 @@ def catalog():
|
|
| 854 |
updateCartButton();
|
| 855 |
setupFilters();
|
| 856 |
|
|
|
|
| 857 |
window.addEventListener('click', function(event) {
|
| 858 |
if (event.target.classList.contains('modal')) {
|
| 859 |
closeModal(event.target.id);
|
| 860 |
}
|
| 861 |
});
|
| 862 |
|
|
|
|
| 863 |
window.addEventListener('keydown', function(event) {
|
| 864 |
if (event.key === 'Escape') {
|
| 865 |
document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
|
|
@@ -872,35 +922,9 @@ def catalog():
|
|
| 872 |
</script>
|
| 873 |
</body>
|
| 874 |
</html>
|
| 875 |
-
|
| 876 |
-
return render_template_string(
|
| 877 |
-
catalog_html,
|
| 878 |
-
products=products_sorted,
|
| 879 |
-
categories=categories,
|
| 880 |
-
repo_id=REPO_ID,
|
| 881 |
-
is_authenticated=is_authenticated,
|
| 882 |
-
store_address=STORE_ADDRESS,
|
| 883 |
-
session=session,
|
| 884 |
-
currency_code=CURRENCY_CODE
|
| 885 |
-
)
|
| 886 |
-
|
| 887 |
-
@app.route('/product/<int:index>')
|
| 888 |
-
def product_detail(index):
|
| 889 |
-
data = load_data()
|
| 890 |
-
all_products = data.get('products', [])
|
| 891 |
-
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 892 |
-
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
| 893 |
-
|
| 894 |
-
is_authenticated = 'user' in session
|
| 895 |
-
try:
|
| 896 |
-
product = products_sorted[index]
|
| 897 |
-
if not product.get('in_stock', True):
|
| 898 |
-
raise IndexError("Товар не в наличии")
|
| 899 |
-
except IndexError:
|
| 900 |
-
logging.warning(f"Attempted access to non-existent or out-of-stock product with index {index}")
|
| 901 |
-
return "Товар не найден или отсутствует в наличии.", 404
|
| 902 |
|
| 903 |
-
|
| 904 |
<div style="padding: 10px;">
|
| 905 |
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #1C6758;">{{ product['name'] }}</h2>
|
| 906 |
<div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
|
|
@@ -942,14 +966,7 @@ def product_detail(index):
|
|
| 942 |
{% endif %}
|
| 943 |
</div>
|
| 944 |
</div>
|
| 945 |
-
|
| 946 |
-
return render_template_string(
|
| 947 |
-
detail_html,
|
| 948 |
-
product=product,
|
| 949 |
-
repo_id=REPO_ID,
|
| 950 |
-
is_authenticated=is_authenticated,
|
| 951 |
-
currency_code=CURRENCY_CODE
|
| 952 |
-
)
|
| 953 |
|
| 954 |
LOGIN_TEMPLATE = '''
|
| 955 |
<!DOCTYPE html>
|
|
@@ -992,83 +1009,7 @@ LOGIN_TEMPLATE = '''
|
|
| 992 |
</html>
|
| 993 |
'''
|
| 994 |
|
| 995 |
-
|
| 996 |
-
def login():
|
| 997 |
-
if request.method == 'POST':
|
| 998 |
-
login = request.form.get('login')
|
| 999 |
-
password = request.form.get('password')
|
| 1000 |
-
if not login or not password:
|
| 1001 |
-
return render_template_string(LOGIN_TEMPLATE, error="Логин и пароль не могут быть пустыми."), 400
|
| 1002 |
-
|
| 1003 |
-
users = load_users()
|
| 1004 |
-
|
| 1005 |
-
if login in users and users[login].get('password') == password:
|
| 1006 |
-
user_info = users[login]
|
| 1007 |
-
session['user'] = login
|
| 1008 |
-
session['user_info'] = {
|
| 1009 |
-
'login': login,
|
| 1010 |
-
'first_name': user_info.get('first_name', ''),
|
| 1011 |
-
'last_name': user_info.get('last_name', ''),
|
| 1012 |
-
'country': user_info.get('country', ''),
|
| 1013 |
-
'city': user_info.get('city', ''),
|
| 1014 |
-
'phone': user_info.get('phone', '')
|
| 1015 |
-
}
|
| 1016 |
-
logging.info(f"User {login} logged in successfully.")
|
| 1017 |
-
login_response_html = f'''
|
| 1018 |
-
<!DOCTYPE html><html><head><title>Перенаправление...</title></head><body>
|
| 1019 |
-
<script>
|
| 1020 |
-
try {{ localStorage.setItem('soolaUser', '{login}'); }} catch (e) {{ console.error("Error saving to localStorage:", e); }}
|
| 1021 |
-
window.location.href = "{url_for('catalog')}";
|
| 1022 |
-
</script>
|
| 1023 |
-
<p>Вход выполнен успешно. Перенаправление в <a href="{url_for('catalog')}">каталог</a>...</p>
|
| 1024 |
-
</body></html>
|
| 1025 |
-
'''
|
| 1026 |
-
return login_response_html
|
| 1027 |
-
else:
|
| 1028 |
-
logging.warning(f"Failed login attempt for user {login}.")
|
| 1029 |
-
error_message = "Неверный логин или пароль."
|
| 1030 |
-
return render_template_string(LOGIN_TEMPLATE, error=error_message), 401
|
| 1031 |
-
|
| 1032 |
-
return render_template_string(LOGIN_TEMPLATE, error=None)
|
| 1033 |
-
|
| 1034 |
-
@app.route('/auto_login', methods=['POST'])
|
| 1035 |
-
def auto_login():
|
| 1036 |
-
data = request.get_json()
|
| 1037 |
-
if not data or 'login' not in data:
|
| 1038 |
-
logging.warning("Auto_login request missing data or login.")
|
| 1039 |
-
return "Invalid request", 400
|
| 1040 |
-
|
| 1041 |
-
login = data.get('login')
|
| 1042 |
-
if not login:
|
| 1043 |
-
logging.warning("Attempted auto_login with empty login.")
|
| 1044 |
-
return "Login not provided", 400
|
| 1045 |
-
|
| 1046 |
-
users = load_users()
|
| 1047 |
-
if login in users:
|
| 1048 |
-
user_info = users[login]
|
| 1049 |
-
session['user'] = login
|
| 1050 |
-
session['user_info'] = {
|
| 1051 |
-
'login': login,
|
| 1052 |
-
'first_name': user_info.get('first_name', ''),
|
| 1053 |
-
'last_name': user_info.get('last_name', ''),
|
| 1054 |
-
'country': user_info.get('country', ''),
|
| 1055 |
-
'city': user_info.get('city', ''),
|
| 1056 |
-
'phone': user_info.get('phone', '')
|
| 1057 |
-
}
|
| 1058 |
-
logging.info(f"Auto-login successful for user {login}.")
|
| 1059 |
-
return "OK", 200
|
| 1060 |
-
else:
|
| 1061 |
-
logging.warning(f"Failed auto-login attempt for non-existent user {login}.")
|
| 1062 |
-
return "Auto-login failed", 400
|
| 1063 |
-
|
| 1064 |
-
@app.route('/logout')
|
| 1065 |
-
def logout():
|
| 1066 |
-
logged_out_user = session.get('user')
|
| 1067 |
-
session.pop('user', None)
|
| 1068 |
-
session.pop('user_info', None)
|
| 1069 |
-
if logged_out_user:
|
| 1070 |
-
logging.info(f"User {logged_out_user} logged out.")
|
| 1071 |
-
logout_response_html = '''
|
| 1072 |
<!DOCTYPE html><html><head><title>Выход...</title></head><body>
|
| 1073 |
<script>
|
| 1074 |
try { localStorage.removeItem('soolaUser'); } catch (e) { console.error("Error removing from localStorage:", e); }
|
|
@@ -1077,7 +1018,129 @@ def logout():
|
|
| 1077 |
<p>Выход выполнен. Перенаправление на <a href="/">главную страницу</a>...</p>
|
| 1078 |
</body></html>
|
| 1079 |
'''
|
| 1080 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1081 |
|
| 1082 |
ADMIN_TEMPLATE = '''
|
| 1083 |
<!DOCTYPE html>
|
|
@@ -1464,6 +1527,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1464 |
if (group) {
|
| 1465 |
const container = group.parentNode;
|
| 1466 |
group.remove();
|
|
|
|
| 1467 |
if (container && container.children.length === 0) {
|
| 1468 |
const placeholderGroup = document.createElement('div');
|
| 1469 |
placeholderGroup.className = 'color-input-group';
|
|
@@ -1482,14 +1546,231 @@ ADMIN_TEMPLATE = '''
|
|
| 1482 |
</html>
|
| 1483 |
'''
|
| 1484 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1485 |
@app.route('/admin', methods=['GET', 'POST'])
|
| 1486 |
def admin():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1487 |
# Load data and users *before* processing POST to have the current state
|
| 1488 |
data = load_data()
|
| 1489 |
users = load_users()
|
| 1490 |
# Ensure products and categories are lists/dicts even if load failed
|
| 1491 |
products = data.get('products', [])
|
| 1492 |
categories = data.get('categories', [])
|
|
|
|
|
|
|
|
|
|
| 1493 |
|
| 1494 |
if request.method == 'POST':
|
| 1495 |
action = request.form.get('action')
|
|
@@ -1500,7 +1781,7 @@ def admin():
|
|
| 1500 |
category_name = request.form.get('category_name', '').strip()
|
| 1501 |
if category_name and category_name not in categories:
|
| 1502 |
categories.append(category_name)
|
| 1503 |
-
categories.sort()
|
| 1504 |
data['categories'] = categories # Update the main data dict
|
| 1505 |
save_data(data)
|
| 1506 |
logging.info(f"Category '{category_name}' added.")
|
|
@@ -1517,15 +1798,16 @@ def admin():
|
|
| 1517 |
if category_to_delete and category_to_delete in categories:
|
| 1518 |
categories.remove(category_to_delete)
|
| 1519 |
updated_count = 0
|
|
|
|
| 1520 |
for product in products:
|
| 1521 |
if product.get('category') == category_to_delete:
|
| 1522 |
product['category'] = 'Без категории'
|
| 1523 |
updated_count += 1
|
| 1524 |
data['categories'] = categories # Update the main data dict
|
| 1525 |
-
data['products'] = products # Update
|
| 1526 |
save_data(data)
|
| 1527 |
logging.info(f"Category '{category_to_delete}' deleted. Updated products: {updated_count}.")
|
| 1528 |
-
flash(f"Категория '{category_to_delete}' удалена.", 'success')
|
| 1529 |
else:
|
| 1530 |
logging.warning(f"Attempted to delete non-existent or empty category: {category_to_delete}")
|
| 1531 |
flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error')
|
|
@@ -1566,13 +1848,14 @@ def admin():
|
|
| 1566 |
if photo and photo.filename:
|
| 1567 |
try:
|
| 1568 |
ext = os.path.splitext(photo.filename)[1].lower()
|
| 1569 |
-
# Basic check for common image extensions
|
| 1570 |
if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
|
| 1571 |
logging.warning(f"Skipping non-image file upload: {photo.filename}")
|
| 1572 |
flash(f"Файл {photo.filename} не является изображением и был пропущен.", "warning")
|
| 1573 |
continue
|
| 1574 |
|
| 1575 |
-
|
|
|
|
|
|
|
| 1576 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
| 1577 |
photo.save(temp_path)
|
| 1578 |
logging.info(f"Uploading photo {photo_filename} to HF for product {name}...")
|
|
@@ -1586,25 +1869,28 @@ def admin():
|
|
| 1586 |
)
|
| 1587 |
photos_list.append(photo_filename)
|
| 1588 |
logging.info(f"Photo {photo_filename} uploaded successfully.")
|
| 1589 |
-
os.remove(temp_path)
|
| 1590 |
uploaded_count += 1
|
| 1591 |
except Exception as e:
|
| 1592 |
logging.error(f"Error uploading photo {photo.filename} to HF: {e}", exc_info=True)
|
| 1593 |
flash(f"Ошибка при загрузке фото {photo.filename}.", 'error')
|
| 1594 |
-
# Optionally remove temp file if it exists after error
|
| 1595 |
if os.path.exists(temp_path):
|
| 1596 |
try: os.remove(temp_path)
|
| 1597 |
-
except OSError: pass
|
| 1598 |
elif photo and not photo.filename:
|
| 1599 |
logging.warning("Received an empty photo file object when adding product.")
|
| 1600 |
try:
|
| 1601 |
-
#
|
| 1602 |
if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
|
| 1603 |
os.rmdir(uploads_dir)
|
| 1604 |
except OSError as e:
|
| 1605 |
logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}")
|
|
|
|
|
|
|
|
|
|
| 1606 |
|
| 1607 |
new_product = {
|
|
|
|
| 1608 |
'name': name, 'price': price, 'description': description,
|
| 1609 |
'category': category if category in categories else 'Без категории',
|
| 1610 |
'photos': photos_list, 'colors': colors,
|
|
@@ -1635,6 +1921,7 @@ def admin():
|
|
| 1635 |
logging.error(f"Invalid index '{index_str}' for editing. Product list length: {len(products)}")
|
| 1636 |
return redirect(url_for('admin'))
|
| 1637 |
|
|
|
|
| 1638 |
product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
|
| 1639 |
price_str = request.form.get('price', str(product_to_edit['price'])).replace(',', '.')
|
| 1640 |
product_to_edit['description'] = request.form.get('description', product_to_edit.get('description', '')).strip()
|
|
@@ -1652,6 +1939,7 @@ def admin():
|
|
| 1652 |
logging.warning(f"Invalid price format '{price_str}' during edit of product {original_name}. Price not changed.")
|
| 1653 |
flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning')
|
| 1654 |
|
|
|
|
| 1655 |
photos_files = request.files.getlist('photos')
|
| 1656 |
if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
|
| 1657 |
uploads_dir = 'uploads_temp'
|
|
@@ -1674,7 +1962,8 @@ def admin():
|
|
| 1674 |
flash(f"Файл {photo.filename} не является изображением и был пропущен.", "warning")
|
| 1675 |
continue
|
| 1676 |
|
| 1677 |
-
|
|
|
|
| 1678 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
| 1679 |
photo.save(temp_path)
|
| 1680 |
logging.info(f"Uploading new photo {photo_filename} to HF...")
|
|
@@ -1698,7 +1987,8 @@ def admin():
|
|
| 1698 |
logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}")
|
| 1699 |
|
| 1700 |
if new_photos_list:
|
| 1701 |
-
logging.info(f"
|
|
|
|
| 1702 |
old_photos = product_to_edit.get('photos', [])
|
| 1703 |
if old_photos:
|
| 1704 |
logging.info(f"Attempting to delete old photos: {old_photos}")
|
|
@@ -1712,12 +2002,17 @@ def admin():
|
|
| 1712 |
)
|
| 1713 |
logging.info(f"Old photos for product {product_to_edit['name']} deleted from HF.")
|
| 1714 |
except Exception as e:
|
|
|
|
| 1715 |
logging.error(f"Error deleting old photos {old_photos} from HF: {e}", exc_info=True)
|
| 1716 |
-
flash("Не удалось удалить старые фотографии с сервера.", "warning")
|
|
|
|
| 1717 |
product_to_edit['photos'] = new_photos_list
|
| 1718 |
flash("Фотографии товара успешно обновлены.", "success")
|
| 1719 |
elif uploaded_count == 0 and any(f.filename for f in photos_files):
|
| 1720 |
-
|
|
|
|
|
|
|
|
|
|
| 1721 |
|
| 1722 |
# Update the product in the main list
|
| 1723 |
products[index] = product_to_edit
|
|
@@ -1738,6 +2033,7 @@ def admin():
|
|
| 1738 |
deleted_product = products.pop(index)
|
| 1739 |
product_name = deleted_product.get('name', 'N/A')
|
| 1740 |
|
|
|
|
| 1741 |
photos_to_delete = deleted_product.get('photos', [])
|
| 1742 |
if photos_to_delete and HF_TOKEN_WRITE:
|
| 1743 |
logging.info(f"Attempting to delete photos for product '{product_name}' from HF: {photos_to_delete}")
|
|
@@ -1752,8 +2048,13 @@ def admin():
|
|
| 1752 |
)
|
| 1753 |
logging.info(f"Photos for product '{product_name}' deleted from HF.")
|
| 1754 |
except Exception as e:
|
|
|
|
| 1755 |
logging.error(f"Error deleting photos {photos_to_delete} for product '{product_name}' from HF: {e}", exc_info=True)
|
| 1756 |
-
flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1757 |
|
| 1758 |
data['products'] = products # Update the main data dict
|
| 1759 |
save_data(data)
|
|
@@ -1780,7 +2081,7 @@ def admin():
|
|
| 1780 |
return redirect(url_for('admin'))
|
| 1781 |
|
| 1782 |
users[login] = {
|
| 1783 |
-
'password': password,
|
| 1784 |
'first_name': first_name, 'last_name': last_name,
|
| 1785 |
'phone': phone,
|
| 1786 |
'country': country, 'city': city
|
|
@@ -1792,6 +2093,10 @@ def admin():
|
|
| 1792 |
elif action == 'delete_user':
|
| 1793 |
login_to_delete = request.form.get('login')
|
| 1794 |
if login_to_delete and login_to_delete in users:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1795 |
del users[login_to_delete]
|
| 1796 |
save_users(users)
|
| 1797 |
logging.info(f"User '{login_to_delete}' deleted.")
|
|
@@ -1804,21 +2109,23 @@ def admin():
|
|
| 1804 |
logging.warning(f"Received unknown admin action: {action}")
|
| 1805 |
flash(f"Неизвестное действие: {action}", 'warning')
|
| 1806 |
|
|
|
|
| 1807 |
return redirect(url_for('admin'))
|
| 1808 |
|
| 1809 |
except Exception as e:
|
|
|
|
| 1810 |
logging.error(f"Error processing admin action '{action}': {e}", exc_info=True)
|
| 1811 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1812 |
return redirect(url_for('admin'))
|
| 1813 |
|
| 1814 |
-
# GET request
|
| 1815 |
-
# Reload data and users again
|
| 1816 |
-
# This ensures the template always shows the latest state after an action
|
| 1817 |
current_data = load_data()
|
| 1818 |
current_users = load_users()
|
| 1819 |
-
|
|
|
|
| 1820 |
display_categories = sorted(current_data.get('categories', []))
|
| 1821 |
-
display_users = dict(sorted(current_users.items()))
|
| 1822 |
|
| 1823 |
return render_template_string(
|
| 1824 |
ADMIN_TEMPLATE,
|
|
@@ -1831,9 +2138,10 @@ def admin():
|
|
| 1831 |
|
| 1832 |
@app.route('/force_upload', methods=['POST'])
|
| 1833 |
def force_upload():
|
|
|
|
| 1834 |
logging.info("Forcing upload to Hugging Face...")
|
| 1835 |
try:
|
| 1836 |
-
upload_db_to_hf()
|
| 1837 |
flash("Данные успешно загружены на Hugging Face.", 'success')
|
| 1838 |
except Exception as e:
|
| 1839 |
logging.error(f"Error during forced upload: {e}", exc_info=True)
|
|
@@ -1842,21 +2150,32 @@ def force_upload():
|
|
| 1842 |
|
| 1843 |
@app.route('/force_download', methods=['POST'])
|
| 1844 |
def force_download():
|
|
|
|
| 1845 |
logging.info("Forcing download from Hugging Face...")
|
| 1846 |
try:
|
| 1847 |
-
if download_db_from_hf():
|
| 1848 |
flash("Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.", 'success')
|
|
|
|
|
|
|
|
|
|
| 1849 |
else:
|
| 1850 |
-
flash("Не удалось скачать данные с Hugging Face после нескольких попыток.", 'error')
|
| 1851 |
except Exception as e:
|
| 1852 |
logging.error(f"Error during forced download: {e}", exc_info=True)
|
| 1853 |
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
|
| 1854 |
return redirect(url_for('admin'))
|
| 1855 |
|
|
|
|
|
|
|
|
|
|
| 1856 |
if __name__ == '__main__':
|
| 1857 |
-
# Initial load on startup
|
| 1858 |
-
|
| 1859 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1860 |
|
| 1861 |
if HF_TOKEN_WRITE:
|
| 1862 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
|
@@ -1868,6 +2187,8 @@ if __name__ == '__main__':
|
|
| 1868 |
port = int(os.environ.get('PORT', 7860))
|
| 1869 |
logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
|
| 1870 |
# Use waitress or gunicorn in production instead of development server
|
| 1871 |
-
# For simplicity, using Flask's dev server here
|
|
|
|
| 1872 |
app.run(debug=False, host='0.0.0.0', port=port)
|
| 1873 |
|
|
|
|
|
|
| 1 |
+
# --- START OF FILE app.py ---
|
| 2 |
|
| 3 |
+
from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash, jsonify
|
| 4 |
import json
|
| 5 |
import os
|
| 6 |
import logging
|
|
|
|
| 12 |
from werkzeug.utils import secure_filename
|
| 13 |
from dotenv import load_dotenv
|
| 14 |
import requests # Added for more specific network error handling
|
| 15 |
+
import uuid # For unique order IDs
|
| 16 |
|
| 17 |
load_dotenv()
|
| 18 |
|
|
|
|
| 23 |
|
| 24 |
SYNC_FILES = [DATA_FILE, USERS_FILE]
|
| 25 |
|
| 26 |
+
REPO_ID = "Kgshop/Soola" # Replace with your actual repo ID if different
|
| 27 |
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
|
| 28 |
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
|
| 29 |
|
|
|
|
| 37 |
|
| 38 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 39 |
|
| 40 |
+
# --- Hugging Face Sync Functions ---
|
| 41 |
+
|
| 42 |
def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
|
| 43 |
if not HF_TOKEN_READ and not HF_TOKEN_WRITE: # Allow read with write token too
|
| 44 |
logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
|
|
|
|
| 74 |
except HfHubHTTPError as e:
|
| 75 |
if e.response.status_code == 404:
|
| 76 |
logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
|
| 77 |
+
# Check if it's the first attempt; if so, create an empty default file locally
|
| 78 |
+
if attempt == 0 and not os.path.exists(file_name):
|
| 79 |
+
try:
|
| 80 |
+
if file_name == DATA_FILE:
|
| 81 |
+
with open(file_name, 'w', encoding='utf-8') as f:
|
| 82 |
+
json.dump({'products': [], 'categories': [], 'orders': {}}, f)
|
| 83 |
+
logging.info(f"Created empty local file {file_name} because it was not found on HF.")
|
| 84 |
+
elif file_name == USERS_FILE:
|
| 85 |
+
with open(file_name, 'w', encoding='utf-8') as f:
|
| 86 |
+
json.dump({}, f)
|
| 87 |
+
logging.info(f"Created empty local file {file_name} because it was not found on HF.")
|
| 88 |
+
except Exception as create_e:
|
| 89 |
+
logging.error(f"Failed to create empty local file {file_name}: {create_e}")
|
| 90 |
success = False # Mark as failed for this specific file, but don't stop others
|
| 91 |
break # No point retrying a 404
|
| 92 |
else:
|
|
|
|
| 106 |
logging.info(f"Download process finished. Overall success: {all_successful}")
|
| 107 |
return all_successful
|
| 108 |
|
| 109 |
+
def upload_db_to_hf(specific_file=None):
|
| 110 |
+
if not HF_TOKEN_WRITE:
|
| 111 |
+
logging.warning("HF_TOKEN (for writing) not set. Skipping upload to Hugging Face.")
|
| 112 |
+
return
|
| 113 |
+
try:
|
| 114 |
+
api = HfApi()
|
| 115 |
+
files_to_upload = [specific_file] if specific_file else SYNC_FILES
|
| 116 |
+
logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
|
| 117 |
+
|
| 118 |
+
for file_name in files_to_upload:
|
| 119 |
+
if os.path.exists(file_name):
|
| 120 |
+
try:
|
| 121 |
+
api.upload_file(
|
| 122 |
+
path_or_fileobj=file_name,
|
| 123 |
+
path_in_repo=file_name,
|
| 124 |
+
repo_id=REPO_ID,
|
| 125 |
+
repo_type="dataset",
|
| 126 |
+
token=HF_TOKEN_WRITE,
|
| 127 |
+
commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
| 128 |
+
)
|
| 129 |
+
logging.info(f"File {file_name} successfully uploaded to Hugging Face.")
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logging.error(f"Error uploading file {file_name} to Hugging Face: {e}")
|
| 132 |
+
else:
|
| 133 |
+
logging.warning(f"File {file_name} not found locally, skipping upload.")
|
| 134 |
+
logging.info("Finished uploading files to HF.")
|
| 135 |
+
except Exception as e:
|
| 136 |
+
logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
|
| 137 |
+
|
| 138 |
+
def periodic_backup():
|
| 139 |
+
backup_interval = 1800
|
| 140 |
+
logging.info(f"Setting up periodic backup every {backup_interval} seconds.")
|
| 141 |
+
while True:
|
| 142 |
+
time.sleep(backup_interval)
|
| 143 |
+
logging.info("Starting periodic backup...")
|
| 144 |
+
upload_db_to_hf()
|
| 145 |
+
logging.info("Periodic backup finished.")
|
| 146 |
+
|
| 147 |
+
# --- Data Loading and Saving Functions ---
|
| 148 |
+
|
| 149 |
def load_data():
|
| 150 |
+
default_data = {'products': [], 'categories': [], 'orders': {}}
|
| 151 |
try:
|
| 152 |
with open(DATA_FILE, 'r', encoding='utf-8') as file:
|
| 153 |
data = json.load(file)
|
|
|
|
| 157 |
raise FileNotFoundError # Treat as missing to trigger download
|
| 158 |
if 'products' not in data: data['products'] = []
|
| 159 |
if 'categories' not in data: data['categories'] = []
|
| 160 |
+
if 'orders' not in data: data['orders'] = {} # Initialize orders if missing
|
| 161 |
return data
|
| 162 |
except FileNotFoundError:
|
| 163 |
logging.warning(f"Local file {DATA_FILE} not found. Attempting download from HF.")
|
|
|
|
| 176 |
return default_data
|
| 177 |
if 'products' not in data: data['products'] = []
|
| 178 |
if 'categories' not in data: data['categories'] = []
|
| 179 |
+
if 'orders' not in data: data['orders'] = {} # Initialize orders if missing
|
| 180 |
return data
|
| 181 |
except FileNotFoundError:
|
| 182 |
logging.error(f"File {DATA_FILE} still not found even after download reported success. Using default.")
|
|
|
|
| 189 |
return default_data
|
| 190 |
else:
|
| 191 |
logging.error(f"Failed to download {DATA_FILE} from HF after retries. Using empty default data structure.")
|
| 192 |
+
# Create empty file locally if download failed and file doesn't exist
|
| 193 |
+
if not os.path.exists(DATA_FILE):
|
| 194 |
+
try:
|
| 195 |
+
with open(DATA_FILE, 'w', encoding='utf-8') as f:
|
| 196 |
+
json.dump(default_data, f)
|
| 197 |
+
logging.info(f"Created empty local file {DATA_FILE} after failed download.")
|
| 198 |
+
except Exception as create_e:
|
| 199 |
+
logging.error(f"Failed to create empty local file {DATA_FILE}: {create_e}")
|
| 200 |
return default_data # Return empty structure in memory
|
| 201 |
|
| 202 |
def save_data(data):
|
|
|
|
| 207 |
return
|
| 208 |
if 'products' not in data: data['products'] = []
|
| 209 |
if 'categories' not in data: data['categories'] = []
|
| 210 |
+
if 'orders' not in data: data['orders'] = {} # Ensure orders key exists
|
| 211 |
|
| 212 |
with open(DATA_FILE, 'w', encoding='utf-8') as file:
|
| 213 |
json.dump(data, file, ensure_ascii=False, indent=4)
|
|
|
|
| 247 |
return default_users
|
| 248 |
else:
|
| 249 |
logging.error(f"Failed to download {USERS_FILE} from HF after retries. Using empty default user structure.")
|
| 250 |
+
# Create empty file locally if download failed and file doesn't exist
|
| 251 |
+
if not os.path.exists(USERS_FILE):
|
| 252 |
+
try:
|
| 253 |
+
with open(USERS_FILE, 'w', encoding='utf-8') as f:
|
| 254 |
+
json.dump(default_users, f)
|
| 255 |
+
logging.info(f"Created empty local file {USERS_FILE} after failed download.")
|
| 256 |
+
except Exception as create_e:
|
| 257 |
+
logging.error(f"Failed to create empty local file {USERS_FILE}: {create_e}")
|
| 258 |
return default_users # Return empty structure in memory
|
| 259 |
|
| 260 |
def save_users(users):
|
|
|
|
| 269 |
except Exception as e:
|
| 270 |
logging.error(f"Error saving user data to {USERS_FILE}: {e}", exc_info=True)
|
| 271 |
|
| 272 |
+
# --- Templates ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
+
CATALOG_TEMPLATE = '''
|
| 275 |
<!DOCTYPE html>
|
| 276 |
<html lang="ru">
|
| 277 |
<head>
|
|
|
|
| 311 |
body.dark-mode .category-filter { background-color: #253f37; border-color: #2c4a41; color: #97b7ae; }
|
| 312 |
.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); }
|
| 313 |
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); }
|
| 314 |
+
.products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 20px; padding: 10px; }
|
| 315 |
+
@media (min-width: 600px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } }
|
| 316 |
+
@media (min-width: 900px) { .products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); } }
|
| 317 |
+
|
| 318 |
.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;}
|
| 319 |
body.dark-mode .product { background: #253f37; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); border-color: #2c4a41; }
|
| 320 |
.product:hover { transform: translateY(-5px) scale(1.02); box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12); }
|
| 321 |
body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); }
|
| 322 |
.product-image { width: 100%; aspect-ratio: 1 / 1; background-color: #fff; border-radius: 10px 10px 0 0; overflow: hidden; display: flex; justify-content: center; align-items: center; margin-bottom: 0; }
|
| 323 |
.product-image img { max-width: 100%; max-height: 100%; object-fit: contain; transition: transform 0.3s ease; }
|
| 324 |
+
/* Removed hover effect from image itself to avoid double scaling */
|
| 325 |
.product-info { padding: 15px; flex-grow: 1; display: flex; flex-direction: column; justify-content: center; }
|
| 326 |
.product h2 { font-size: 1.1rem; font-weight: 600; margin: 0 0 8px 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #2d332f; }
|
| 327 |
body.dark-mode .product h2 { color: #c8d8d3; }
|
|
|
|
| 353 |
.cart-item:last-child { border-bottom: none; }
|
| 354 |
.cart-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: #fff; padding: 5px; grid-column: 1; }
|
| 355 |
.cart-item-details { grid-column: 2; }
|
| 356 |
+
.cart-item-details strong { display: block; margin-bottom: 5px; font-size: 1rem; }
|
| 357 |
.cart-item-price { font-size: 0.9rem; color: #44524c; }
|
| 358 |
body.dark-mode .cart-item-price { color: #8aa39a; }
|
| 359 |
+
.cart-item-total { font-weight: bold; text-align: right; grid-column: 3; font-size: 1rem;}
|
| 360 |
.cart-item-remove { grid-column: 4; background:none; border:none; color:#f56565; cursor:pointer; font-size: 1.3em; padding: 5px; line-height: 1; }
|
| 361 |
.cart-item-remove:hover { color: #c53030; }
|
| 362 |
.quantity-input, .color-select { width: 100%; max-width: 180px; padding: 10px; border: 1px solid #d1e7dd; border-radius: 8px; font-size: 1rem; margin: 10px 0; box-sizing: border-box; }
|
|
|
|
| 368 |
.cart-actions .product-button { width: auto; flex-grow: 1; }
|
| 369 |
.clear-cart { background-color: #7a8d85; }
|
| 370 |
.clear-cart:hover { background-color: #5e6e68; box-shadow: 0 4px 15px rgba(94, 110, 104, 0.4); }
|
| 371 |
+
.formulate-order-button { background-color: #38a169; }
|
| 372 |
+
.formulate-order-button:hover { background-color: #2f855a; box-shadow: 0 4px 15px rgba(47, 133, 90, 0.4); }
|
| 373 |
.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;}
|
| 374 |
.notification.show { opacity: 1;}
|
| 375 |
.no-results-message { grid-column: 1 / -1; text-align: center; padding: 40px; font-size: 1.1rem; color: #5e6e68; }
|
|
|
|
| 482 |
<button class="product-button clear-cart" onclick="clearCart()">
|
| 483 |
<i class="fas fa-trash"></i> Очистить корзину
|
| 484 |
</button>
|
| 485 |
+
<button class="product-button formulate-order-button" onclick="formulateOrder()">
|
| 486 |
+
<i class="fas fa-file-alt"></i> Сформировать заказ
|
| 487 |
</button>
|
| 488 |
</div>
|
| 489 |
</div>
|
|
|
|
| 607 |
function openQuantityModal(index) {
|
| 608 |
if (!isAuthenticated) {
|
| 609 |
alert('Пожалуйста, войдите в систему, чтобы добавить товар в корзину.');
|
| 610 |
+
window.location.href = '{{ url_for("login") }}';
|
| 611 |
return;
|
| 612 |
}
|
| 613 |
selectedProductIndex = index;
|
|
|
|
| 666 |
return;
|
| 667 |
}
|
| 668 |
|
| 669 |
+
const cartItemId = `${product.name}-${color}`; // Use name + color as ID
|
| 670 |
const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
|
| 671 |
|
| 672 |
if (existingItemIndex > -1) {
|
| 673 |
cart[existingItemIndex].quantity += quantity;
|
| 674 |
} else {
|
| 675 |
cart.push({
|
| 676 |
+
id: cartItemId, // Unique ID for the item *variant* in the cart
|
| 677 |
name: product.name,
|
| 678 |
price: product.price,
|
| 679 |
photo: product.photos && product.photos.length > 0 ? product.photos[0] : null,
|
|
|
|
| 748 |
function removeFromCart(itemId) {
|
| 749 |
cart = cart.filter(item => item.id !== itemId);
|
| 750 |
localStorage.setItem('soolaCart', JSON.stringify(cart));
|
| 751 |
+
openCartModal(); // Refresh cart modal view
|
| 752 |
updateCartButton();
|
| 753 |
}
|
| 754 |
|
|
|
|
| 756 |
if (confirm("Вы уверены, что хотите очистить корзину?")) {
|
| 757 |
cart = [];
|
| 758 |
localStorage.removeItem('soolaCart');
|
| 759 |
+
openCartModal(); // Refresh cart modal view
|
| 760 |
updateCartButton();
|
| 761 |
}
|
| 762 |
}
|
| 763 |
|
| 764 |
+
function formulateOrder() {
|
| 765 |
if (cart.length === 0) {
|
| 766 |
+
alert("Корзина пуста! Добавьте товары перед формированием заказа.");
|
| 767 |
return;
|
| 768 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 769 |
|
| 770 |
+
const orderData = {
|
| 771 |
+
cart: cart,
|
| 772 |
+
userInfo: isAuthenticated ? userInfo : null // Include user info if logged in
|
| 773 |
+
};
|
| 774 |
+
|
| 775 |
+
// Disable button to prevent multiple clicks
|
| 776 |
+
const formulateButton = document.querySelector('.formulate-order-button');
|
| 777 |
+
if (formulateButton) formulateButton.disabled = true;
|
| 778 |
+
|
| 779 |
+
showNotification("Формируем заказ...", 5000);
|
| 780 |
+
|
| 781 |
+
fetch('/create_order', {
|
| 782 |
+
method: 'POST',
|
| 783 |
+
headers: { 'Content-Type': 'application/json' },
|
| 784 |
+
body: JSON.stringify(orderData)
|
| 785 |
+
})
|
| 786 |
+
.then(response => {
|
| 787 |
+
if (!response.ok) {
|
| 788 |
+
return response.json().then(err => { throw new Error(err.error || 'Не удалось создать заказ'); });
|
| 789 |
+
}
|
| 790 |
+
return response.json();
|
| 791 |
+
})
|
| 792 |
+
.then(data => {
|
| 793 |
+
if (data.order_id) {
|
| 794 |
+
localStorage.removeItem('soolaCart'); // Clear cart on success
|
| 795 |
+
cart = []; // Clear local cart variable
|
| 796 |
+
updateCartButton(); // Update button display
|
| 797 |
+
closeModal('cartModal'); // Close cart modal
|
| 798 |
+
window.location.href = `/order/${data.order_id}`; // Redirect to order page
|
| 799 |
+
} else {
|
| 800 |
+
throw new Error('Не получен ID заказа от сервера.');
|
| 801 |
+
}
|
| 802 |
+
})
|
| 803 |
+
.catch(error => {
|
| 804 |
+
console.error('Ошибка при формировании заказа:', error);
|
| 805 |
+
alert(`Ошибка: ${error.message}`);
|
| 806 |
+
if (formulateButton) formulateButton.disabled = false; // Re-enable button on error
|
| 807 |
+
});
|
| 808 |
}
|
| 809 |
|
| 810 |
|
|
|
|
| 860 |
filterProducts();
|
| 861 |
});
|
| 862 |
});
|
| 863 |
+
filterProducts(); // Initial filter application
|
| 864 |
}
|
| 865 |
|
| 866 |
function showNotification(message, duration = 3000) {
|
| 867 |
const placeholder = document.getElementById('notification-placeholder');
|
| 868 |
+
if (!placeholder) {
|
| 869 |
+
// Create placeholder if it doesn't exist
|
| 870 |
+
const newPlaceholder = document.createElement('div');
|
| 871 |
+
newPlaceholder.id = 'notification-placeholder';
|
| 872 |
+
newPlaceholder.style.position = 'fixed';
|
| 873 |
+
newPlaceholder.style.bottom = '80px'; // Adjust position as needed
|
| 874 |
+
newPlaceholder.style.left = '50%';
|
| 875 |
+
newPlaceholder.style.transform = 'translateX(-50%)';
|
| 876 |
+
newPlaceholder.style.zIndex = '1002';
|
| 877 |
+
document.body.appendChild(newPlaceholder);
|
| 878 |
+
placeholder = newPlaceholder;
|
| 879 |
+
}
|
| 880 |
+
|
| 881 |
|
| 882 |
const notification = document.createElement('div');
|
| 883 |
notification.className = 'notification';
|
| 884 |
notification.textContent = message;
|
| 885 |
placeholder.appendChild(notification);
|
| 886 |
|
| 887 |
+
// Force reflow before adding class for transition
|
| 888 |
+
void notification.offsetWidth;
|
| 889 |
+
|
| 890 |
+
notification.classList.add('show');
|
| 891 |
|
| 892 |
setTimeout(() => {
|
| 893 |
notification.classList.remove('show');
|
| 894 |
+
// Remove element after transition finishes
|
| 895 |
+
notification.addEventListener('transitionend', () => notification.remove());
|
| 896 |
}, duration);
|
| 897 |
}
|
| 898 |
|
|
|
|
| 902 |
updateCartButton();
|
| 903 |
setupFilters();
|
| 904 |
|
| 905 |
+
// Close modal on background click
|
| 906 |
window.addEventListener('click', function(event) {
|
| 907 |
if (event.target.classList.contains('modal')) {
|
| 908 |
closeModal(event.target.id);
|
| 909 |
}
|
| 910 |
});
|
| 911 |
|
| 912 |
+
// Close modal on Escape key press
|
| 913 |
window.addEventListener('keydown', function(event) {
|
| 914 |
if (event.key === 'Escape') {
|
| 915 |
document.querySelectorAll('.modal[style*="display: block"]').forEach(modal => {
|
|
|
|
| 922 |
</script>
|
| 923 |
</body>
|
| 924 |
</html>
|
| 925 |
+
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 926 |
|
| 927 |
+
PRODUCT_DETAIL_TEMPLATE = '''
|
| 928 |
<div style="padding: 10px;">
|
| 929 |
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; text-align: center; color: #1C6758;">{{ product['name'] }}</h2>
|
| 930 |
<div class="swiper-container" style="max-width: 450px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
|
|
|
|
| 966 |
{% endif %}
|
| 967 |
</div>
|
| 968 |
</div>
|
| 969 |
+
'''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 970 |
|
| 971 |
LOGIN_TEMPLATE = '''
|
| 972 |
<!DOCTYPE html>
|
|
|
|
| 1009 |
</html>
|
| 1010 |
'''
|
| 1011 |
|
| 1012 |
+
LOGOUT_REDIRECT_TEMPLATE = '''
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1013 |
<!DOCTYPE html><html><head><title>Выход...</title></head><body>
|
| 1014 |
<script>
|
| 1015 |
try { localStorage.removeItem('soolaUser'); } catch (e) { console.error("Error removing from localStorage:", e); }
|
|
|
|
| 1018 |
<p>Выход выполнен. Перенаправление на <a href="/">главную страницу</a>...</p>
|
| 1019 |
</body></html>
|
| 1020 |
'''
|
| 1021 |
+
|
| 1022 |
+
LOGIN_REDIRECT_TEMPLATE = '''
|
| 1023 |
+
<!DOCTYPE html><html><head><title>Перенаправление...</title></head><body>
|
| 1024 |
+
<script>
|
| 1025 |
+
try {{ localStorage.setItem('soolaUser', '{login}'); }} catch (e) {{ console.error("Error saving to localStorage:", e); }}
|
| 1026 |
+
window.location.href = "{catalog_url}";
|
| 1027 |
+
</script>
|
| 1028 |
+
<p>Вход выполнен успешно. Перенаправление в <a href="{catalog_url}">каталог</a>...</p>
|
| 1029 |
+
</body></html>
|
| 1030 |
+
'''
|
| 1031 |
+
|
| 1032 |
+
ORDER_TEMPLATE = '''
|
| 1033 |
+
<!DOCTYPE html>
|
| 1034 |
+
<html lang="ru">
|
| 1035 |
+
<head>
|
| 1036 |
+
<meta charset="UTF-8">
|
| 1037 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 1038 |
+
<title>Заказ №{{ order.id }} - Soola Cosmetics</title>
|
| 1039 |
+
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
|
| 1040 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
| 1041 |
+
<style>
|
| 1042 |
+
body { font-family: 'Poppins', sans-serif; background: #f0f9f4; color: #2d332f; line-height: 1.6; padding: 20px; }
|
| 1043 |
+
.container { max-width: 800px; margin: 20px auto; padding: 30px; background: #fff; border-radius: 15px; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); border: 1px solid #d1e7dd; }
|
| 1044 |
+
h1 { text-align: center; color: #1C6758; margin-bottom: 25px; font-size: 1.8rem; font-weight: 600; }
|
| 1045 |
+
h2 { color: #164B41; margin-top: 30px; margin-bottom: 15px; font-size: 1.4rem; border-bottom: 1px solid #d1e7dd; padding-bottom: 8px;}
|
| 1046 |
+
.order-meta { font-size: 0.9rem; color: #5e6e68; margin-bottom: 20px; text-align: center; }
|
| 1047 |
+
.order-item { display: grid; grid-template-columns: 60px 1fr auto; gap: 15px; align-items: center; padding: 15px 0; border-bottom: 1px solid #e1f0e9; }
|
| 1048 |
+
.order-item:last-child { border-bottom: none; }
|
| 1049 |
+
.order-item img { width: 60px; height: 60px; object-fit: contain; border-radius: 8px; background-color: #fff; padding: 5px; border: 1px solid #e1f0e9;}
|
| 1050 |
+
.item-details strong { display: block; margin-bottom: 5px; font-size: 1.05rem; color: #2d332f;}
|
| 1051 |
+
.item-details span { font-size: 0.9rem; color: #44524c; display: block;}
|
| 1052 |
+
.item-total { font-weight: bold; text-align: right; font-size: 1rem; color: #1C6758;}
|
| 1053 |
+
.order-summary { margin-top: 30px; padding-top: 20px; border-top: 2px solid #1C6758; text-align: right; }
|
| 1054 |
+
.order-summary p { margin-bottom: 10px; font-size: 1.1rem; }
|
| 1055 |
+
.order-summary strong { font-size: 1.3rem; color: #1C6758; }
|
| 1056 |
+
.customer-info { margin-top: 30px; background-color: #f8fcfb; padding: 20px; border-radius: 8px; border: 1px solid #e1f0e9;}
|
| 1057 |
+
.customer-info p { margin-bottom: 8px; font-size: 0.95rem; }
|
| 1058 |
+
.customer-info strong { color: #164B41; }
|
| 1059 |
+
.actions { margin-top: 30px; text-align: center; }
|
| 1060 |
+
.button { padding: 12px 25px; border: none; border-radius: 8px; background-color: #25D366; /* WhatsApp Green */ color: white; font-weight: 600; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; font-size: 1rem; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; }
|
| 1061 |
+
.button:hover { background-color: #128C7E; }
|
| 1062 |
+
.button:active { transform: scale(0.98); }
|
| 1063 |
+
.button i { font-size: 1.2rem; }
|
| 1064 |
+
.catalog-link { display: block; text-align: center; margin-top: 25px; color: #3D8361; text-decoration: none; font-size: 0.9rem; }
|
| 1065 |
+
.catalog-link:hover { text-decoration: underline; }
|
| 1066 |
+
.not-found { text-align: center; color: #c53030; font-size: 1.2rem; padding: 40px 0;}
|
| 1067 |
+
</style>
|
| 1068 |
+
</head>
|
| 1069 |
+
<body>
|
| 1070 |
+
<div class="container">
|
| 1071 |
+
{% if order %}
|
| 1072 |
+
<h1><i class="fas fa-receipt"></i> Ваш Заказ №{{ order.id }}</h1>
|
| 1073 |
+
<p class="order-meta">Дата создания: {{ order.created_at }}</p>
|
| 1074 |
+
|
| 1075 |
+
<h2><i class="fas fa-shopping-bag"></i> Товары в заказе</h2>
|
| 1076 |
+
<div id="orderItems">
|
| 1077 |
+
{% for item in order.cart %}
|
| 1078 |
+
<div class="order-item">
|
| 1079 |
+
<img src="{{ item.photo_url }}" alt="{{ item.name }}">
|
| 1080 |
+
<div class="item-details">
|
| 1081 |
+
<strong>{{ item.name }} {% if item.color != 'N/A' %}({{ item.color }}){% endif %}</strong>
|
| 1082 |
+
<span>{{ "%.2f"|format(item.price) }} {{ currency_code }} × {{ item.quantity }}</span>
|
| 1083 |
+
</div>
|
| 1084 |
+
<div class="item-total">
|
| 1085 |
+
{{ "%.2f"|format(item.price * item.quantity) }} {{ currency_code }}
|
| 1086 |
+
</div>
|
| 1087 |
+
</div>
|
| 1088 |
+
{% endfor %}
|
| 1089 |
+
</div>
|
| 1090 |
+
|
| 1091 |
+
<div class="order-summary">
|
| 1092 |
+
<p>Общая сумма товаров: <strong>{{ "%.2f"|format(order.total_price) }} {{ currency_code }}</strong></p>
|
| 1093 |
+
{# Add shipping/discount info here if applicable in the future #}
|
| 1094 |
+
<p><strong>ИТОГО К ОПЛАТЕ: {{ "%.2f"|format(order.total_price) }} {{ currency_code }}</strong></p>
|
| 1095 |
+
</div>
|
| 1096 |
+
|
| 1097 |
+
{% if order.user_info %}
|
| 1098 |
+
<div class="customer-info">
|
| 1099 |
+
<h2><i class="fas fa-user"></i> Информация о клиенте</h2>
|
| 1100 |
+
<p><strong>Логин:</strong> {{ order.user_info.login }}</p>
|
| 1101 |
+
<p><strong>Имя:</strong> {{ order.user_info.get('first_name', '') }} {{ order.user_info.get('last_name', '') }}</p>
|
| 1102 |
+
<p><strong>Телефон:</strong> {{ order.user_info.get('phone', 'Не указан') }}</p>
|
| 1103 |
+
<p><strong>Страна:</strong> {{ order.user_info.get('country', 'Не указана') }}</p>
|
| 1104 |
+
<p><strong>Город:</strong> {{ order.user_info.get('city', 'Не указан') }}</p>
|
| 1105 |
+
</div>
|
| 1106 |
+
{% else %}
|
| 1107 |
+
<div class="customer-info">
|
| 1108 |
+
<h2><i class="fas fa-user-slash"></i> Информация о клиенте</h2>
|
| 1109 |
+
<p>Клиент не был авторизован при создании заказа.</p>
|
| 1110 |
+
</div>
|
| 1111 |
+
{% endif %}
|
| 1112 |
+
|
| 1113 |
+
<div class="actions">
|
| 1114 |
+
<button class="button" onclick="sendOrderViaWhatsApp()"><i class="fab fa-whatsapp"></i> Отправить заказ</button>
|
| 1115 |
+
</div>
|
| 1116 |
+
|
| 1117 |
+
<a href="{{ url_for('catalog') }}" class="catalog-link">← Вернуться в каталог</a>
|
| 1118 |
+
|
| 1119 |
+
<script>
|
| 1120 |
+
function sendOrderViaWhatsApp() {
|
| 1121 |
+
const orderId = '{{ order.id }}';
|
| 1122 |
+
const orderUrl = `{{ request.url }}`; // Gets the current page URL
|
| 1123 |
+
const whatsappNumber = "996997703090"; // Replace with your number
|
| 1124 |
+
|
| 1125 |
+
let message = `Здравствуйте! Хочу подтвердить свой заказ на Soola Cosmetics:%0A%0A`;
|
| 1126 |
+
message += `*Номер заказа:* ${orderId}%0A`;
|
| 1127 |
+
message += `*Ссылка на заказ:* ${encodeURIComponent(orderUrl)}%0A%0A`;
|
| 1128 |
+
message += `Пожалуйста, свяжитесь со мной для уточнения деталей оплаты и доставки.`;
|
| 1129 |
+
|
| 1130 |
+
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${message}`;
|
| 1131 |
+
window.open(whatsappUrl, '_blank');
|
| 1132 |
+
}
|
| 1133 |
+
</script>
|
| 1134 |
+
|
| 1135 |
+
{% else %}
|
| 1136 |
+
<h1 style="color: #c53030;"><i class="fas fa-exclamation-triangle"></i> Ошибка</h1>
|
| 1137 |
+
<p class="not-found">Заказ с таким ID не найден.</p>
|
| 1138 |
+
<a href="{{ url_for('catalog') }}" class="catalog-link">← Вернуться в каталог</a>
|
| 1139 |
+
{% endif %}
|
| 1140 |
+
</div>
|
| 1141 |
+
</body>
|
| 1142 |
+
</html>
|
| 1143 |
+
'''
|
| 1144 |
|
| 1145 |
ADMIN_TEMPLATE = '''
|
| 1146 |
<!DOCTYPE html>
|
|
|
|
| 1527 |
if (group) {
|
| 1528 |
const container = group.parentNode;
|
| 1529 |
group.remove();
|
| 1530 |
+
// Add a placeholder input if the container becomes empty
|
| 1531 |
if (container && container.children.length === 0) {
|
| 1532 |
const placeholderGroup = document.createElement('div');
|
| 1533 |
placeholderGroup.className = 'color-input-group';
|
|
|
|
| 1546 |
</html>
|
| 1547 |
'''
|
| 1548 |
|
| 1549 |
+
# --- Flask Routes ---
|
| 1550 |
+
|
| 1551 |
+
@app.route('/')
|
| 1552 |
+
def catalog():
|
| 1553 |
+
data = load_data()
|
| 1554 |
+
all_products = data.get('products', [])
|
| 1555 |
+
categories = sorted(data.get('categories', [])) # Sort categories alphabetically
|
| 1556 |
+
is_authenticated = 'user' in session
|
| 1557 |
+
|
| 1558 |
+
# Filter out-of-stock and sort: top first, then by name
|
| 1559 |
+
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 1560 |
+
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
| 1561 |
+
|
| 1562 |
+
return render_template_string(
|
| 1563 |
+
CATALOG_TEMPLATE,
|
| 1564 |
+
products=products_sorted,
|
| 1565 |
+
categories=categories,
|
| 1566 |
+
repo_id=REPO_ID,
|
| 1567 |
+
is_authenticated=is_authenticated,
|
| 1568 |
+
store_address=STORE_ADDRESS,
|
| 1569 |
+
session=session,
|
| 1570 |
+
currency_code=CURRENCY_CODE
|
| 1571 |
+
)
|
| 1572 |
+
|
| 1573 |
+
@app.route('/product/<int:index>')
|
| 1574 |
+
def product_detail(index):
|
| 1575 |
+
data = load_data()
|
| 1576 |
+
all_products = data.get('products', [])
|
| 1577 |
+
# We need the same filtering and sorting logic as in catalog to match the index
|
| 1578 |
+
products_in_stock = [p for p in all_products if p.get('in_stock', True)]
|
| 1579 |
+
products_sorted = sorted(products_in_stock, key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()))
|
| 1580 |
+
|
| 1581 |
+
is_authenticated = 'user' in session
|
| 1582 |
+
try:
|
| 1583 |
+
product = products_sorted[index]
|
| 1584 |
+
# No need to check in_stock again, already filtered
|
| 1585 |
+
except IndexError:
|
| 1586 |
+
logging.warning(f"Attempted access to non-existent or out-of-stock product with index {index}")
|
| 1587 |
+
return "Товар не найден или отсутствует в наличии.", 404
|
| 1588 |
+
|
| 1589 |
+
return render_template_string(
|
| 1590 |
+
PRODUCT_DETAIL_TEMPLATE,
|
| 1591 |
+
product=product,
|
| 1592 |
+
repo_id=REPO_ID,
|
| 1593 |
+
is_authenticated=is_authenticated,
|
| 1594 |
+
currency_code=CURRENCY_CODE
|
| 1595 |
+
)
|
| 1596 |
+
|
| 1597 |
+
@app.route('/login', methods=['GET', 'POST'])
|
| 1598 |
+
def login():
|
| 1599 |
+
if request.method == 'POST':
|
| 1600 |
+
login_attempt = request.form.get('login')
|
| 1601 |
+
password = request.form.get('password')
|
| 1602 |
+
if not login_attempt or not password:
|
| 1603 |
+
return render_template_string(LOGIN_TEMPLATE, error="Логин и пароль не могут быть пустыми."), 400
|
| 1604 |
+
|
| 1605 |
+
users = load_users()
|
| 1606 |
+
|
| 1607 |
+
if login_attempt in users and users[login_attempt].get('password') == password:
|
| 1608 |
+
user_info = users[login_attempt]
|
| 1609 |
+
session['user'] = login_attempt
|
| 1610 |
+
session['user_info'] = {
|
| 1611 |
+
'login': login_attempt,
|
| 1612 |
+
'first_name': user_info.get('first_name', ''),
|
| 1613 |
+
'last_name': user_info.get('last_name', ''),
|
| 1614 |
+
'country': user_info.get('country', ''),
|
| 1615 |
+
'city': user_info.get('city', ''),
|
| 1616 |
+
'phone': user_info.get('phone', '')
|
| 1617 |
+
}
|
| 1618 |
+
logging.info(f"User {login_attempt} logged in successfully.")
|
| 1619 |
+
# Use template for redirect script
|
| 1620 |
+
return render_template_string(LOGIN_REDIRECT_TEMPLATE,
|
| 1621 |
+
login=login_attempt,
|
| 1622 |
+
catalog_url=url_for('catalog'))
|
| 1623 |
+
else:
|
| 1624 |
+
logging.warning(f"Failed login attempt for user {login_attempt}.")
|
| 1625 |
+
error_message = "Неверный логин или пароль."
|
| 1626 |
+
return render_template_string(LOGIN_TEMPLATE, error=error_message), 401
|
| 1627 |
+
|
| 1628 |
+
# GET request
|
| 1629 |
+
return render_template_string(LOGIN_TEMPLATE, error=None)
|
| 1630 |
+
|
| 1631 |
+
@app.route('/auto_login', methods=['POST'])
|
| 1632 |
+
def auto_login():
|
| 1633 |
+
data = request.get_json()
|
| 1634 |
+
if not data or 'login' not in data:
|
| 1635 |
+
logging.warning("Auto_login request missing data or login.")
|
| 1636 |
+
return jsonify({"error": "Invalid request"}), 400
|
| 1637 |
+
|
| 1638 |
+
login_attempt = data.get('login')
|
| 1639 |
+
if not login_attempt:
|
| 1640 |
+
logging.warning("Attempted auto_login with empty login.")
|
| 1641 |
+
return jsonify({"error": "Login not provided"}), 400
|
| 1642 |
+
|
| 1643 |
+
users = load_users()
|
| 1644 |
+
if login_attempt in users:
|
| 1645 |
+
user_info = users[login_attempt]
|
| 1646 |
+
session['user'] = login_attempt
|
| 1647 |
+
session['user_info'] = {
|
| 1648 |
+
'login': login_attempt,
|
| 1649 |
+
'first_name': user_info.get('first_name', ''),
|
| 1650 |
+
'last_name': user_info.get('last_name', ''),
|
| 1651 |
+
'country': user_info.get('country', ''),
|
| 1652 |
+
'city': user_info.get('city', ''),
|
| 1653 |
+
'phone': user_info.get('phone', '')
|
| 1654 |
+
}
|
| 1655 |
+
logging.info(f"Auto-login successful for user {login_attempt}.")
|
| 1656 |
+
return jsonify({"message": "OK"}), 200
|
| 1657 |
+
else:
|
| 1658 |
+
logging.warning(f"Failed auto-login attempt for non-existent user {login_attempt}.")
|
| 1659 |
+
# Don't reveal user existence, just fail generically
|
| 1660 |
+
return jsonify({"error": "Auto-login failed"}), 401 # Use 401 Unauthorized
|
| 1661 |
+
|
| 1662 |
+
@app.route('/logout')
|
| 1663 |
+
def logout():
|
| 1664 |
+
logged_out_user = session.get('user')
|
| 1665 |
+
session.pop('user', None)
|
| 1666 |
+
session.pop('user_info', None)
|
| 1667 |
+
if logged_out_user:
|
| 1668 |
+
logging.info(f"User {logged_out_user} logged out.")
|
| 1669 |
+
# Use template for redirect script
|
| 1670 |
+
return render_template_string(LOGOUT_REDIRECT_TEMPLATE)
|
| 1671 |
+
|
| 1672 |
+
@app.route('/create_order', methods=['POST'])
|
| 1673 |
+
def create_order():
|
| 1674 |
+
order_data = request.get_json()
|
| 1675 |
+
|
| 1676 |
+
if not order_data or 'cart' not in order_data or not order_data['cart']:
|
| 1677 |
+
logging.warning("Create order request missing cart data or cart is empty.")
|
| 1678 |
+
return jsonify({"error": "Корзина пуста или не передана."}), 400
|
| 1679 |
+
|
| 1680 |
+
cart_items = order_data['cart']
|
| 1681 |
+
user_info = order_data.get('userInfo', None) # User info might be null if not logged in
|
| 1682 |
+
|
| 1683 |
+
# Recalculate total server-side for security/consistency
|
| 1684 |
+
total_price = 0
|
| 1685 |
+
processed_cart = []
|
| 1686 |
+
for item in cart_items:
|
| 1687 |
+
# Basic validation (can be expanded)
|
| 1688 |
+
if not all(k in item for k in ('name', 'price', 'quantity')):
|
| 1689 |
+
logging.error(f"Invalid cart item structure received: {item}")
|
| 1690 |
+
return jsonify({"error": "Неверный формат товара в корзине."}), 400
|
| 1691 |
+
try:
|
| 1692 |
+
price = float(item['price'])
|
| 1693 |
+
quantity = int(item['quantity'])
|
| 1694 |
+
if price < 0 or quantity <= 0:
|
| 1695 |
+
raise ValueError("Invalid price or quantity")
|
| 1696 |
+
total_price += price * quantity
|
| 1697 |
+
# Construct item data for saving
|
| 1698 |
+
processed_cart.append({
|
| 1699 |
+
"name": item['name'],
|
| 1700 |
+
"price": price,
|
| 1701 |
+
"quantity": quantity,
|
| 1702 |
+
"color": item.get('color', 'N/A'),
|
| 1703 |
+
"photo": item.get('photo'), # Store photo filename if available
|
| 1704 |
+
# Generate photo URL here for easier display later
|
| 1705 |
+
"photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photo']}" if item.get('photo') else "https://via.placeholder.com/60x60.png?text=N/A"
|
| 1706 |
+
})
|
| 1707 |
+
except (ValueError, TypeError) as e:
|
| 1708 |
+
logging.error(f"Invalid price/quantity in cart item: {item}. Error: {e}")
|
| 1709 |
+
return jsonify({"error": "Неверная цена или количество в товаре."}), 400
|
| 1710 |
+
|
| 1711 |
+
order_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
| 1712 |
+
order_timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
| 1713 |
+
|
| 1714 |
+
new_order = {
|
| 1715 |
+
"id": order_id,
|
| 1716 |
+
"created_at": order_timestamp,
|
| 1717 |
+
"cart": processed_cart,
|
| 1718 |
+
"total_price": round(total_price, 2),
|
| 1719 |
+
"user_info": user_info, # Can be None
|
| 1720 |
+
"status": "new" # Initial status
|
| 1721 |
+
}
|
| 1722 |
+
|
| 1723 |
+
try:
|
| 1724 |
+
data = load_data()
|
| 1725 |
+
# Ensure 'orders' key exists and is a dictionary
|
| 1726 |
+
if 'orders' not in data or not isinstance(data.get('orders'), dict):
|
| 1727 |
+
data['orders'] = {}
|
| 1728 |
+
|
| 1729 |
+
data['orders'][order_id] = new_order
|
| 1730 |
+
save_data(data)
|
| 1731 |
+
logging.info(f"Order {order_id} created successfully.")
|
| 1732 |
+
return jsonify({"order_id": order_id}), 201 # 201 Created status code
|
| 1733 |
+
|
| 1734 |
+
except Exception as e:
|
| 1735 |
+
logging.error(f"Failed to save order {order_id}: {e}", exc_info=True)
|
| 1736 |
+
return jsonify({"error": "Ошибка сервера при сохранении заказа."}), 500
|
| 1737 |
+
|
| 1738 |
+
@app.route('/order/<order_id>')
|
| 1739 |
+
def view_order(order_id):
|
| 1740 |
+
# No login required for this route
|
| 1741 |
+
data = load_data()
|
| 1742 |
+
order = data.get('orders', {}).get(order_id)
|
| 1743 |
+
|
| 1744 |
+
if order:
|
| 1745 |
+
# Format timestamp for display if needed
|
| 1746 |
+
# order['created_at_formatted'] = ...
|
| 1747 |
+
logging.info(f"Displaying order {order_id}")
|
| 1748 |
+
else:
|
| 1749 |
+
logging.warning(f"Order {order_id} not found.")
|
| 1750 |
+
|
| 1751 |
+
return render_template_string(ORDER_TEMPLATE,
|
| 1752 |
+
order=order,
|
| 1753 |
+
repo_id=REPO_ID,
|
| 1754 |
+
currency_code=CURRENCY_CODE)
|
| 1755 |
+
|
| 1756 |
+
|
| 1757 |
@app.route('/admin', methods=['GET', 'POST'])
|
| 1758 |
def admin():
|
| 1759 |
+
# Simple admin check (replace with proper authentication/authorization)
|
| 1760 |
+
# For now, allow anyone access for demonstration, add security later
|
| 1761 |
+
# if 'user' not in session or session.get('user') != 'admin_user':
|
| 1762 |
+
# flash("Доступ запрещен.", "error")
|
| 1763 |
+
# return redirect(url_for('login'))
|
| 1764 |
+
|
| 1765 |
# Load data and users *before* processing POST to have the current state
|
| 1766 |
data = load_data()
|
| 1767 |
users = load_users()
|
| 1768 |
# Ensure products and categories are lists/dicts even if load failed
|
| 1769 |
products = data.get('products', [])
|
| 1770 |
categories = data.get('categories', [])
|
| 1771 |
+
# Ensure 'orders' key exists and is a dictionary
|
| 1772 |
+
if 'orders' not in data or not isinstance(data.get('orders'), dict):
|
| 1773 |
+
data['orders'] = {}
|
| 1774 |
|
| 1775 |
if request.method == 'POST':
|
| 1776 |
action = request.form.get('action')
|
|
|
|
| 1781 |
category_name = request.form.get('category_name', '').strip()
|
| 1782 |
if category_name and category_name not in categories:
|
| 1783 |
categories.append(category_name)
|
| 1784 |
+
# categories.sort() # Keep sorted list
|
| 1785 |
data['categories'] = categories # Update the main data dict
|
| 1786 |
save_data(data)
|
| 1787 |
logging.info(f"Category '{category_name}' added.")
|
|
|
|
| 1798 |
if category_to_delete and category_to_delete in categories:
|
| 1799 |
categories.remove(category_to_delete)
|
| 1800 |
updated_count = 0
|
| 1801 |
+
# Update products that used this category
|
| 1802 |
for product in products:
|
| 1803 |
if product.get('category') == category_to_delete:
|
| 1804 |
product['category'] = 'Без категории'
|
| 1805 |
updated_count += 1
|
| 1806 |
data['categories'] = categories # Update the main data dict
|
| 1807 |
+
data['products'] = products # Update affected products
|
| 1808 |
save_data(data)
|
| 1809 |
logging.info(f"Category '{category_to_delete}' deleted. Updated products: {updated_count}.")
|
| 1810 |
+
flash(f"Категория '{category_to_delete}' удалена. {updated_count} товаров обновлено.", 'success')
|
| 1811 |
else:
|
| 1812 |
logging.warning(f"Attempted to delete non-existent or empty category: {category_to_delete}")
|
| 1813 |
flash(f"Не удалось удалить категорию '{category_to_delete}'.", 'error')
|
|
|
|
| 1848 |
if photo and photo.filename:
|
| 1849 |
try:
|
| 1850 |
ext = os.path.splitext(photo.filename)[1].lower()
|
|
|
|
| 1851 |
if ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
|
| 1852 |
logging.warning(f"Skipping non-image file upload: {photo.filename}")
|
| 1853 |
flash(f"Файл {photo.filename} не является изображением и был пропущен.", "warning")
|
| 1854 |
continue
|
| 1855 |
|
| 1856 |
+
# Create a unique filename based on product name and time
|
| 1857 |
+
safe_name = secure_filename(name.replace(' ', '_'))[:50] # Limit length
|
| 1858 |
+
photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
|
| 1859 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
| 1860 |
photo.save(temp_path)
|
| 1861 |
logging.info(f"Uploading photo {photo_filename} to HF for product {name}...")
|
|
|
|
| 1869 |
)
|
| 1870 |
photos_list.append(photo_filename)
|
| 1871 |
logging.info(f"Photo {photo_filename} uploaded successfully.")
|
| 1872 |
+
os.remove(temp_path) # Clean up temp file
|
| 1873 |
uploaded_count += 1
|
| 1874 |
except Exception as e:
|
| 1875 |
logging.error(f"Error uploading photo {photo.filename} to HF: {e}", exc_info=True)
|
| 1876 |
flash(f"Ошибка при загрузке фото {photo.filename}.", 'error')
|
|
|
|
| 1877 |
if os.path.exists(temp_path):
|
| 1878 |
try: os.remove(temp_path)
|
| 1879 |
+
except OSError: pass # Ignore error if file cannot be removed
|
| 1880 |
elif photo and not photo.filename:
|
| 1881 |
logging.warning("Received an empty photo file object when adding product.")
|
| 1882 |
try:
|
| 1883 |
+
# Clean up temp dir if empty
|
| 1884 |
if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
|
| 1885 |
os.rmdir(uploads_dir)
|
| 1886 |
except OSError as e:
|
| 1887 |
logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}")
|
| 1888 |
+
elif not HF_TOKEN_WRITE and photos_files and any(f.filename for f in photos_files):
|
| 1889 |
+
flash("HF_TOKEN (write) не настроен. Фотографии не были загружены.", "warning")
|
| 1890 |
+
|
| 1891 |
|
| 1892 |
new_product = {
|
| 1893 |
+
# Consider adding a unique ID here later if needed
|
| 1894 |
'name': name, 'price': price, 'description': description,
|
| 1895 |
'category': category if category in categories else 'Без категории',
|
| 1896 |
'photos': photos_list, 'colors': colors,
|
|
|
|
| 1921 |
logging.error(f"Invalid index '{index_str}' for editing. Product list length: {len(products)}")
|
| 1922 |
return redirect(url_for('admin'))
|
| 1923 |
|
| 1924 |
+
# Update fields
|
| 1925 |
product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
|
| 1926 |
price_str = request.form.get('price', str(product_to_edit['price'])).replace(',', '.')
|
| 1927 |
product_to_edit['description'] = request.form.get('description', product_to_edit.get('description', '')).strip()
|
|
|
|
| 1939 |
logging.warning(f"Invalid price format '{price_str}' during edit of product {original_name}. Price not changed.")
|
| 1940 |
flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning')
|
| 1941 |
|
| 1942 |
+
# Handle photo replacement
|
| 1943 |
photos_files = request.files.getlist('photos')
|
| 1944 |
if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
|
| 1945 |
uploads_dir = 'uploads_temp'
|
|
|
|
| 1962 |
flash(f"Файл {photo.filename} не является изображением и был пропущен.", "warning")
|
| 1963 |
continue
|
| 1964 |
|
| 1965 |
+
safe_name = secure_filename(product_to_edit['name'].replace(' ', '_'))[:50]
|
| 1966 |
+
photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
|
| 1967 |
temp_path = os.path.join(uploads_dir, photo_filename)
|
| 1968 |
photo.save(temp_path)
|
| 1969 |
logging.info(f"Uploading new photo {photo_filename} to HF...")
|
|
|
|
| 1987 |
logging.warning(f"Could not remove temporary upload directory {uploads_dir}: {e}")
|
| 1988 |
|
| 1989 |
if new_photos_list:
|
| 1990 |
+
logging.info(f"New photo list for product {product_to_edit['name']} generated.")
|
| 1991 |
+
# Delete old photos from HF *after* new ones are uploaded
|
| 1992 |
old_photos = product_to_edit.get('photos', [])
|
| 1993 |
if old_photos:
|
| 1994 |
logging.info(f"Attempting to delete old photos: {old_photos}")
|
|
|
|
| 2002 |
)
|
| 2003 |
logging.info(f"Old photos for product {product_to_edit['name']} deleted from HF.")
|
| 2004 |
except Exception as e:
|
| 2005 |
+
# Log error but continue, new photos are already uploaded
|
| 2006 |
logging.error(f"Error deleting old photos {old_photos} from HF: {e}", exc_info=True)
|
| 2007 |
+
flash("Не удалось удалить старые фотографии с сервера. Новые фото загружены.", "warning")
|
| 2008 |
+
# Update product data with new photo list
|
| 2009 |
product_to_edit['photos'] = new_photos_list
|
| 2010 |
flash("Фотографии товара успешно обновлены.", "success")
|
| 2011 |
elif uploaded_count == 0 and any(f.filename for f in photos_files):
|
| 2012 |
+
# Files were selected, but none were uploaded (e.g., wrong format)
|
| 2013 |
+
flash("Не удалось загрузить новые фотографии (возможно, неверный формат).", "error")
|
| 2014 |
+
elif not HF_TOKEN_WRITE and photos_files and any(f.filename for f in photos_files):
|
| 2015 |
+
flash("HF_TOKEN (write) не настроен. Фотографии не были обновлены.", "warning")
|
| 2016 |
|
| 2017 |
# Update the product in the main list
|
| 2018 |
products[index] = product_to_edit
|
|
|
|
| 2033 |
deleted_product = products.pop(index)
|
| 2034 |
product_name = deleted_product.get('name', 'N/A')
|
| 2035 |
|
| 2036 |
+
# Attempt to delete associated photos from HF
|
| 2037 |
photos_to_delete = deleted_product.get('photos', [])
|
| 2038 |
if photos_to_delete and HF_TOKEN_WRITE:
|
| 2039 |
logging.info(f"Attempting to delete photos for product '{product_name}' from HF: {photos_to_delete}")
|
|
|
|
| 2048 |
)
|
| 2049 |
logging.info(f"Photos for product '{product_name}' deleted from HF.")
|
| 2050 |
except Exception as e:
|
| 2051 |
+
# Log error but proceed with local deletion
|
| 2052 |
logging.error(f"Error deleting photos {photos_to_delete} for product '{product_name}' from HF: {e}", exc_info=True)
|
| 2053 |
+
flash(f"Не удалось удалить фото для товара '{product_name}' с сервера. Товар удален локально.", "warning")
|
| 2054 |
+
elif photos_to_delete and not HF_TOKEN_WRITE:
|
| 2055 |
+
logging.warning(f"HF_TOKEN (write) not set. Cannot delete photos {photos_to_delete} for deleted product '{product_name}'.")
|
| 2056 |
+
flash(f"Товар '{product_name}' удален локально, но фото не удалены с сервера (токен не задан).", "warning")
|
| 2057 |
+
|
| 2058 |
|
| 2059 |
data['products'] = products # Update the main data dict
|
| 2060 |
save_data(data)
|
|
|
|
| 2081 |
return redirect(url_for('admin'))
|
| 2082 |
|
| 2083 |
users[login] = {
|
| 2084 |
+
'password': password, # WARNING: Storing plain text
|
| 2085 |
'first_name': first_name, 'last_name': last_name,
|
| 2086 |
'phone': phone,
|
| 2087 |
'country': country, 'city': city
|
|
|
|
| 2093 |
elif action == 'delete_user':
|
| 2094 |
login_to_delete = request.form.get('login')
|
| 2095 |
if login_to_delete and login_to_delete in users:
|
| 2096 |
+
# Optional: Prevent deleting the current admin user?
|
| 2097 |
+
# if login_to_delete == session.get('user'):
|
| 2098 |
+
# flash("Нельзя удалить текущего пользователя.", 'error')
|
| 2099 |
+
# return redirect(url_for('admin'))
|
| 2100 |
del users[login_to_delete]
|
| 2101 |
save_users(users)
|
| 2102 |
logging.info(f"User '{login_to_delete}' deleted.")
|
|
|
|
| 2109 |
logging.warning(f"Received unknown admin action: {action}")
|
| 2110 |
flash(f"Неизвестное действие: {action}", 'warning')
|
| 2111 |
|
| 2112 |
+
# Redirect after POST to prevent form resubmission
|
| 2113 |
return redirect(url_for('admin'))
|
| 2114 |
|
| 2115 |
except Exception as e:
|
| 2116 |
+
# Catch potential errors during file operations, HF API calls, etc.
|
| 2117 |
logging.error(f"Error processing admin action '{action}': {e}", exc_info=True)
|
| 2118 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 2119 |
return redirect(url_for('admin'))
|
| 2120 |
|
| 2121 |
+
# --- GET request ---
|
| 2122 |
+
# Reload data and users again to ensure the template shows the latest state
|
|
|
|
| 2123 |
current_data = load_data()
|
| 2124 |
current_users = load_users()
|
| 2125 |
+
# Sort products for consistent display in admin panel (e.g., by name)
|
| 2126 |
+
display_products = sorted(current_data.get('products', []), key=lambda p: p.get('name', '').lower())
|
| 2127 |
display_categories = sorted(current_data.get('categories', []))
|
| 2128 |
+
display_users = dict(sorted(current_users.items())) # Sort users by login
|
| 2129 |
|
| 2130 |
return render_template_string(
|
| 2131 |
ADMIN_TEMPLATE,
|
|
|
|
| 2138 |
|
| 2139 |
@app.route('/force_upload', methods=['POST'])
|
| 2140 |
def force_upload():
|
| 2141 |
+
# Add admin check here if needed
|
| 2142 |
logging.info("Forcing upload to Hugging Face...")
|
| 2143 |
try:
|
| 2144 |
+
upload_db_to_hf() # Uploads both files by default
|
| 2145 |
flash("Данные успешно загружены на Hugging Face.", 'success')
|
| 2146 |
except Exception as e:
|
| 2147 |
logging.error(f"Error during forced upload: {e}", exc_info=True)
|
|
|
|
| 2150 |
|
| 2151 |
@app.route('/force_download', methods=['POST'])
|
| 2152 |
def force_download():
|
| 2153 |
+
# Add admin check here if needed
|
| 2154 |
logging.info("Forcing download from Hugging Face...")
|
| 2155 |
try:
|
| 2156 |
+
if download_db_from_hf(): # Downloads both files by default
|
| 2157 |
flash("Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.", 'success')
|
| 2158 |
+
# Reload data/users in memory after download might be needed depending on app structure
|
| 2159 |
+
# load_data()
|
| 2160 |
+
# load_users()
|
| 2161 |
else:
|
| 2162 |
+
flash("Не удалось скачать данные с Hugging Face после нескольких попыток. Проверьте логи.", 'error')
|
| 2163 |
except Exception as e:
|
| 2164 |
logging.error(f"Error during forced download: {e}", exc_info=True)
|
| 2165 |
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
|
| 2166 |
return redirect(url_for('admin'))
|
| 2167 |
|
| 2168 |
+
|
| 2169 |
+
# --- App Initialization ---
|
| 2170 |
+
|
| 2171 |
if __name__ == '__main__':
|
| 2172 |
+
# Initial download/load on startup
|
| 2173 |
+
logging.info("Application starting up. Performing initial data load/download...")
|
| 2174 |
+
download_db_from_hf() # Attempt to download both files first
|
| 2175 |
+
load_data() # Load data (will use downloaded or default)
|
| 2176 |
+
load_users() # Load users (will use downloaded or default)
|
| 2177 |
+
logging.info("Initial data load complete.")
|
| 2178 |
+
|
| 2179 |
|
| 2180 |
if HF_TOKEN_WRITE:
|
| 2181 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
|
|
|
| 2187 |
port = int(os.environ.get('PORT', 7860))
|
| 2188 |
logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
|
| 2189 |
# Use waitress or gunicorn in production instead of development server
|
| 2190 |
+
# For simplicity, using Flask's dev server here (debug=False for production-like behavior)
|
| 2191 |
+
# Consider using waitress: pip install waitress; waitress-serve --host=0.0.0.0 --port=7860 app:app
|
| 2192 |
app.run(debug=False, host='0.0.0.0', port=port)
|
| 2193 |
|
| 2194 |
+
# --- END OF FILE app.py ---
|