Update app.py
Browse files
app.py
CHANGED
|
@@ -8,9 +8,10 @@ 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 |
|
|
@@ -18,6 +19,7 @@ app = Flask(__name__)
|
|
| 18 |
app.secret_key = 'your_unique_secret_key_soola_cosmetics_67890'
|
| 19 |
DATA_FILE = 'data_soola.json'
|
| 20 |
USERS_FILE = 'users_soola.json'
|
|
|
|
| 21 |
|
| 22 |
SYNC_FILES = [DATA_FILE, USERS_FILE]
|
| 23 |
|
|
@@ -32,104 +34,132 @@ 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
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
if 'products' not in data:
|
| 45 |
-
data['products'] = []
|
| 46 |
-
if 'categories' not in data:
|
| 47 |
-
data['categories'] = []
|
| 48 |
-
return data
|
| 49 |
-
except FileNotFoundError:
|
| 50 |
-
logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачать с HF.")
|
| 51 |
-
try:
|
| 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 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
| 75 |
except json.JSONDecodeError:
|
| 76 |
-
logging.error(f"Ошибка декодирования JSON в локальном {
|
| 77 |
return {'products': [], 'categories': []}
|
| 78 |
except Exception as e:
|
| 79 |
-
logging.error(f"Неизвестная ошибка при загрузке данных ({
|
| 80 |
return {'products': [], 'categories': []}
|
| 81 |
|
| 82 |
|
| 83 |
-
def save_data(data):
|
| 84 |
-
try:
|
| 85 |
-
with open(DATA_FILE, 'w', encoding='utf-8') as file:
|
| 86 |
-
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 87 |
-
logging.info(f"Данные успешно сохранены в {DATA_FILE}")
|
| 88 |
-
upload_db_to_hf(specific_file=DATA_FILE)
|
| 89 |
-
except Exception as e:
|
| 90 |
-
logging.error(f"Ошибка при сохранении данных в {DATA_FILE}: {e}", exc_info=True)
|
| 91 |
-
|
| 92 |
def load_users():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
try:
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
| 98 |
except FileNotFoundError:
|
| 99 |
-
logging.
|
| 100 |
-
|
| 101 |
-
|
| 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"Ошибка декодирования JSON в локальном {
|
| 118 |
return {}
|
| 119 |
except Exception as e:
|
| 120 |
-
logging.error(f"Неизвестная ошибка при загрузке пользователей ({
|
| 121 |
return {}
|
| 122 |
|
| 123 |
-
|
| 124 |
-
try:
|
| 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("Переменная окружения HF_TOKEN (для записи) не установлена. Загрузка на Hugging Face пропущена.")
|
|
@@ -141,15 +171,39 @@ def upload_db_to_hf(specific_file=None):
|
|
| 141 |
|
| 142 |
for file_name in files_to_upload:
|
| 143 |
if os.path.exists(file_name):
|
|
|
|
| 144 |
try:
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
logging.info(f"Файл {file_name} успешно загружен на Hugging Face.")
|
| 154 |
except Exception as e:
|
| 155 |
logging.error(f"Ошибка при загрузке файла {file_name} на Hugging Face: {e}")
|
|
@@ -159,52 +213,53 @@ def upload_db_to_hf(specific_file=None):
|
|
| 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 |
-
|
| 167 |
-
|
| 168 |
-
downloaded_files_count = 0
|
| 169 |
try:
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
except
|
| 193 |
-
logging.error(f"
|
|
|
|
| 194 |
except Exception as e:
|
| 195 |
-
logging.error(f"
|
| 196 |
|
| 197 |
|
|
|
|
| 198 |
def periodic_backup():
|
| 199 |
backup_interval = 1800
|
| 200 |
logging.info(f"Настройка периодического резервного копирования каждые {backup_interval} секунд.")
|
| 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()
|
|
@@ -322,7 +377,11 @@ def catalog():
|
|
| 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>
|
| 328 |
<body>
|
|
@@ -342,6 +401,16 @@ def catalog():
|
|
| 342 |
</button>
|
| 343 |
</div>
|
| 344 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
<div class="store-address">Наш адрес: {{ store_address }}</div>
|
| 346 |
|
| 347 |
<div class="filters-container">
|
|
@@ -486,7 +555,7 @@ def catalog():
|
|
| 486 |
window.location.reload();
|
| 487 |
} else {
|
| 488 |
response.text().then(text => console.log(`Auto-login failed: ${response.status} ${text}`));
|
| 489 |
-
localStorage.removeItem('soolaUser');
|
| 490 |
}
|
| 491 |
})
|
| 492 |
.catch(error => {
|
|
@@ -583,6 +652,11 @@ def catalog():
|
|
| 583 |
} else {
|
| 584 |
colorSelect.style.display = 'none';
|
| 585 |
if(colorLabel) colorLabel.style.display = 'none';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
}
|
| 587 |
|
| 588 |
document.getElementById('quantityInput').value = 1;
|
|
@@ -714,47 +788,49 @@ 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";
|
|
|
|
| 754 |
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
|
| 755 |
window.open(whatsappUrl, '_blank');
|
| 756 |
}
|
| 757 |
|
|
|
|
| 758 |
function filterProducts() {
|
| 759 |
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
|
| 760 |
const activeCategoryButton = document.querySelector('.category-filter.active');
|
|
@@ -807,7 +883,7 @@ def catalog():
|
|
| 807 |
filterProducts();
|
| 808 |
});
|
| 809 |
});
|
| 810 |
-
filterProducts();
|
| 811 |
}
|
| 812 |
|
| 813 |
function showNotification(message, duration = 3000) {
|
|
@@ -917,8 +993,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>
|
|
@@ -1040,6 +1117,7 @@ def auto_login():
|
|
| 1040 |
return "OK", 200
|
| 1041 |
else:
|
| 1042 |
logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}.")
|
|
|
|
| 1043 |
return "Ошибка авто-входа", 400
|
| 1044 |
|
| 1045 |
@app.route('/logout')
|
|
@@ -1150,14 +1228,14 @@ ADMIN_TEMPLATE = '''
|
|
| 1150 |
<div class="section">
|
| 1151 |
<h2><i class="fas fa-sync-alt"></i> Синхронизация с Датацентром</h2>
|
| 1152 |
<div class="sync-buttons">
|
| 1153 |
-
<form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно загрузить локальные данные на сервер? Это перезапишет данные на
|
| 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="Скачать файлы (перезапишет локальные)"><i class="fas fa-download"></i> Скачать БД</button>
|
| 1158 |
</form>
|
| 1159 |
</div>
|
| 1160 |
-
<p style="font-size: 0.85rem; color: #5e6e68;">Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения
|
| 1161 |
</div>
|
| 1162 |
|
| 1163 |
|
|
@@ -1240,7 +1318,11 @@ ADMIN_TEMPLATE = '''
|
|
| 1240 |
<input type="hidden" name="login" value="{{ login }}">
|
| 1241 |
<button type="submit" class="delete-button"><i class="fas fa-user-slash"></i> Удалить</button>
|
| 1242 |
</form>
|
|
|
|
|
|
|
| 1243 |
</div>
|
|
|
|
|
|
|
| 1244 |
</div>
|
| 1245 |
{% endfor %}
|
| 1246 |
</div>
|
|
@@ -1304,7 +1386,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 +1410,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 %}
|
|
@@ -1364,7 +1447,7 @@ ADMIN_TEMPLATE = '''
|
|
| 1364 |
</select>
|
| 1365 |
<label>Заменить фотографии (выберите новые файлы, до 10 шт.):</label>
|
| 1366 |
<input type="file" name="photos" accept="image/*" multiple>
|
| 1367 |
-
{% if product.get('photos') %}
|
| 1368 |
<p style="font-size: 0.85rem; margin-top: 5px;">Текущие фото:</p>
|
| 1369 |
<div class="photo-preview">
|
| 1370 |
{% for photo in product['photos'] %}
|
|
@@ -1375,14 +1458,13 @@ ADMIN_TEMPLATE = '''
|
|
| 1375 |
<label>Цвета/Варианты:</label>
|
| 1376 |
<div id="edit-color-inputs-{{ loop.index0 }}">
|
| 1377 |
{% set current_colors = product.get('colors', []) %}
|
| 1378 |
-
{%
|
| 1379 |
-
|
| 1380 |
-
|
| 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">
|
|
@@ -1444,10 +1526,8 @@ 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';
|
|
@@ -1491,7 +1571,7 @@ def admin():
|
|
| 1491 |
flash("Название категории не может быть пустым.", 'error')
|
| 1492 |
else:
|
| 1493 |
logging.warning(f"Категория '{category_name}' уже существует.")
|
| 1494 |
-
flash(f"Категория '{category_name}' уже существует.", '
|
| 1495 |
|
| 1496 |
elif action == 'delete_category':
|
| 1497 |
category_to_delete = request.form.get('category_name')
|
|
@@ -1533,46 +1613,59 @@ def admin():
|
|
| 1533 |
return redirect(url_for('admin'))
|
| 1534 |
|
| 1535 |
photos_list = []
|
| 1536 |
-
if photos_files and
|
| 1537 |
-
|
| 1538 |
-
|
| 1539 |
-
|
| 1540 |
-
|
| 1541 |
-
|
| 1542 |
-
|
| 1543 |
-
|
| 1544 |
-
|
| 1545 |
-
|
| 1546 |
-
|
| 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 |
-
|
| 1575 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1576 |
|
| 1577 |
|
| 1578 |
new_product = {
|
|
@@ -1581,7 +1674,12 @@ def admin():
|
|
| 1581 |
'photos': photos_list, 'colors': colors,
|
| 1582 |
'in_stock': in_stock, 'is_top': is_top
|
| 1583 |
}
|
| 1584 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1585 |
|
| 1586 |
save_data(data)
|
| 1587 |
logging.info(f"Товар '{name}' добавлен.")
|
|
@@ -1595,15 +1693,14 @@ def admin():
|
|
| 1595 |
|
| 1596 |
try:
|
| 1597 |
index = int(index_str)
|
| 1598 |
-
|
| 1599 |
-
# We need to find the *original* index in the unsorted/unfiltered list
|
| 1600 |
-
original_product_list = data.get('products', [])
|
| 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 |
product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
|
|
@@ -1625,63 +1722,80 @@ def admin():
|
|
| 1625 |
flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning')
|
| 1626 |
|
| 1627 |
photos_files = request.files.getlist('photos')
|
| 1628 |
-
if photos_files and any(f.filename for f in photos_files)
|
| 1629 |
-
|
| 1630 |
-
|
| 1631 |
-
|
| 1632 |
-
|
| 1633 |
-
|
| 1634 |
-
|
| 1635 |
-
|
| 1636 |
-
|
| 1637 |
-
|
| 1638 |
-
|
| 1639 |
-
|
| 1640 |
-
|
| 1641 |
-
|
| 1642 |
-
|
| 1643 |
-
|
| 1644 |
-
|
| 1645 |
-
|
| 1646 |
-
|
| 1647 |
-
|
| 1648 |
-
|
| 1649 |
-
|
| 1650 |
-
|
| 1651 |
-
|
| 1652 |
-
|
| 1653 |
-
|
| 1654 |
-
|
| 1655 |
-
|
| 1656 |
-
|
| 1657 |
-
|
| 1658 |
-
|
| 1659 |
-
|
| 1660 |
-
|
| 1661 |
-
|
| 1662 |
-
|
| 1663 |
-
|
| 1664 |
-
|
| 1665 |
-
|
| 1666 |
-
|
| 1667 |
-
|
| 1668 |
-
|
| 1669 |
-
|
| 1670 |
-
|
| 1671 |
-
|
| 1672 |
-
|
| 1673 |
-
|
| 1674 |
-
|
| 1675 |
-
|
| 1676 |
-
|
| 1677 |
-
|
| 1678 |
-
|
| 1679 |
-
|
| 1680 |
-
|
| 1681 |
-
|
| 1682 |
-
|
| 1683 |
-
|
| 1684 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1685 |
|
| 1686 |
|
| 1687 |
save_data(data)
|
|
@@ -1696,38 +1810,46 @@ def admin():
|
|
| 1696 |
return redirect(url_for('admin'))
|
| 1697 |
try:
|
| 1698 |
index = int(index_str)
|
| 1699 |
-
original_product_list = data.get('products', [])
|
| 1700 |
if not (0 <= index < len(original_product_list)): raise IndexError("Индекс вне диапазона")
|
|
|
|
| 1701 |
deleted_product = original_product_list.pop(index)
|
| 1702 |
product_name = deleted_product.get('name', 'N/A')
|
| 1703 |
|
| 1704 |
photos_to_delete = deleted_product.get('photos', [])
|
| 1705 |
-
if photos_to_delete
|
| 1706 |
-
|
| 1707 |
-
|
| 1708 |
-
|
| 1709 |
-
|
| 1710 |
-
|
| 1711 |
-
|
| 1712 |
-
|
| 1713 |
-
|
| 1714 |
-
|
| 1715 |
-
|
| 1716 |
-
|
| 1717 |
-
|
| 1718 |
-
|
| 1719 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1720 |
|
| 1721 |
save_data(data)
|
| 1722 |
logging.info(f"Товар '{product_name}' (индекс {index}) удален.")
|
| 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', '').strip()
|
| 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()
|
|
@@ -1768,26 +1890,23 @@ def admin():
|
|
| 1768 |
|
| 1769 |
return redirect(url_for('admin'))
|
| 1770 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1771 |
except Exception as e:
|
| 1772 |
logging.error(f"Ошибка при обработке действия '{action}' в админ-панели: {e}", exc_info=True)
|
| 1773 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1774 |
return redirect(url_for('admin'))
|
| 1775 |
|
| 1776 |
-
#
|
| 1777 |
-
original_products_with_indices = list(enumerate(data.get('products', [])))
|
| 1778 |
-
# Sort the indexed list for display purposes if needed, but keep original index
|
| 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,
|
|
@@ -1798,8 +1917,9 @@ def admin():
|
|
| 1798 |
def force_upload():
|
| 1799 |
logging.info("Запущена принудительная загрузка данных на Hugging Face...")
|
| 1800 |
try:
|
|
|
|
| 1801 |
upload_db_to_hf()
|
| 1802 |
-
flash("
|
| 1803 |
except Exception as e:
|
| 1804 |
logging.error(f"Ошибка при принудительной загрузке: {e}", exc_info=True)
|
| 1805 |
flash(f"Ошибка при загрузке на Hugging Face: {e}", 'error')
|
|
@@ -1809,8 +1929,14 @@ def force_upload():
|
|
| 1809 |
def force_download():
|
| 1810 |
logging.info("Запущено принудительное скачивание данных с Hugging Face...")
|
| 1811 |
try:
|
| 1812 |
-
download_db_from_hf()
|
| 1813 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1814 |
except Exception as e:
|
| 1815 |
logging.error(f"Ошибка при принудительном скачивании: {e}", exc_info=True)
|
| 1816 |
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
|
|
@@ -1818,6 +1944,7 @@ def force_download():
|
|
| 1818 |
|
| 1819 |
|
| 1820 |
if __name__ == '__main__':
|
|
|
|
| 1821 |
load_data()
|
| 1822 |
load_users()
|
| 1823 |
|
|
@@ -1826,9 +1953,9 @@ if __name__ == '__main__':
|
|
| 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"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
|
|
|
|
| 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 filelock # Добавлен импорт
|
| 15 |
|
| 16 |
load_dotenv()
|
| 17 |
|
|
|
|
| 19 |
app.secret_key = 'your_unique_secret_key_soola_cosmetics_67890'
|
| 20 |
DATA_FILE = 'data_soola.json'
|
| 21 |
USERS_FILE = 'users_soola.json'
|
| 22 |
+
LOCK_FILE_SUFFIX = ".lock"
|
| 23 |
|
| 24 |
SYNC_FILES = [DATA_FILE, USERS_FILE]
|
| 25 |
|
|
|
|
| 34 |
|
| 35 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 36 |
|
| 37 |
+
# --- Helper for file locking ---
|
| 38 |
+
def get_lock(filename):
|
| 39 |
+
return filelock.FileLock(filename + LOCK_FILE_SUFFIX, timeout=10) # 10 секунд таймаут
|
| 40 |
+
|
| 41 |
+
# --- Modified Download Function with Retries ---
|
| 42 |
+
def download_db_from_hf(specific_file=None, max_retries=3, retry_delay=5):
|
| 43 |
+
if not HF_TOKEN_READ:
|
| 44 |
+
logging.warning("Переменная окружения HF_TOKEN_READ не установлена. Попытка скачивания с Hugging Face без токена.")
|
| 45 |
+
|
| 46 |
+
files_to_download = [specific_file] if specific_file else SYNC_FILES
|
| 47 |
+
logging.info(f"Начало скачивания файлов {files_to_download} с HF репозитория {REPO_ID}...")
|
| 48 |
+
all_successful = True
|
| 49 |
+
|
| 50 |
+
for file_name in files_to_download:
|
| 51 |
+
download_successful = False
|
| 52 |
+
for attempt in range(max_retries):
|
| 53 |
+
try:
|
| 54 |
+
logging.info(f"Попытка {attempt + 1}/{max_retries} скачивания файла {file_name}...")
|
| 55 |
+
lock = get_lock(file_name)
|
| 56 |
+
with lock:
|
| 57 |
+
local_path = hf_hub_download(
|
| 58 |
+
repo_id=REPO_ID,
|
| 59 |
+
filename=file_name,
|
| 60 |
+
repo_type="dataset",
|
| 61 |
+
token=HF_TOKEN_READ,
|
| 62 |
+
local_dir=".",
|
| 63 |
+
local_dir_use_symlinks=False,
|
| 64 |
+
force_download=True,
|
| 65 |
+
resume_download=False # Добавлено для большей надежности при перезаписи
|
| 66 |
+
)
|
| 67 |
+
logging.info(f"Файл {file_name} успешно скачан из Hugging Face в {local_path}.")
|
| 68 |
+
download_successful = True
|
| 69 |
+
break # Выход из цикла ретраев при успехе
|
| 70 |
+
except RepositoryNotFoundError:
|
| 71 |
+
logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.")
|
| 72 |
+
all_successful = False
|
| 73 |
+
return False # Нет смысла ретраить, если репо нет
|
| 74 |
+
except HfHubHTTPError as e:
|
| 75 |
+
if "404" in str(e) or "not found" in str(e).lower():
|
| 76 |
+
logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} (попытка {attempt + 1}/{max_retries}). Пропуск скачивания этого файла.")
|
| 77 |
+
# Не считаем это полным провалом, просто файла нет на HF
|
| 78 |
+
download_successful = True # Считаем "успешным" в плане отсутствия ошибки скачивания
|
| 79 |
+
break
|
| 80 |
+
else:
|
| 81 |
+
logging.error(f"Ошибка HTTP при скачивании {file_name} (попытка {attempt + 1}/{max_retries}): {e}")
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face (попытка {attempt + 1}/{max_retries}): {e}", exc_info=True)
|
| 84 |
+
|
| 85 |
+
if attempt < max_retries - 1:
|
| 86 |
+
logging.info(f"Ожидание {retry_delay} секунд перед следующей попыткой...")
|
| 87 |
+
time.sleep(retry_delay)
|
| 88 |
+
else:
|
| 89 |
+
logging.error(f"Не удалось скачать файл {file_name} после {max_retries} попыток.")
|
| 90 |
+
all_successful = False
|
| 91 |
+
|
| 92 |
+
if not download_successful:
|
| 93 |
+
all_successful = False # Помечаем общую неуспешность если хотя бы один файл не скачался
|
| 94 |
+
|
| 95 |
+
logging.info(f"Скачивание файлов с HF завершено. Общий результат: {'Успех' if all_successful else 'Неудача'}.")
|
| 96 |
+
return all_successful
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# --- Modified Load Functions ---
|
| 100 |
def load_data():
|
| 101 |
+
file_path = DATA_FILE
|
| 102 |
+
logging.info(f"Попытка загрузки данных из {file_path}...")
|
| 103 |
+
download_success = download_db_from_hf(specific_file=file_path)
|
| 104 |
+
|
| 105 |
+
if download_success:
|
| 106 |
+
logging.info(f"С��ачивание {file_path} с HF успешно (или файл отсутствовал на HF). Попытка загрузки локальной версии.")
|
| 107 |
+
else:
|
| 108 |
+
logging.warning(f"Не удалось скачать {file_path} с HF после всех попыток. Попытка загрузить существующий локальный файл (если есть).")
|
| 109 |
+
|
| 110 |
try:
|
| 111 |
+
lock = get_lock(file_path)
|
| 112 |
+
with lock:
|
| 113 |
+
with open(file_path, 'r', encoding='utf-8') as file:
|
| 114 |
+
data = json.load(file)
|
| 115 |
+
logging.info(f"Данные успешно загружены из локального файла {file_path}")
|
| 116 |
+
if not isinstance(data, dict):
|
| 117 |
+
logging.warning(f"{file_path} не является словарем. Инициализация пустой структурой.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
return {'products': [], 'categories': []}
|
| 119 |
+
if 'products' not in data: data['products'] = []
|
| 120 |
+
if 'categories' not in data: data['categories'] = []
|
| 121 |
+
return data
|
| 122 |
+
except FileNotFoundError:
|
| 123 |
+
logging.critical(f"КРИТИЧЕСКАЯ ОШИБКА: Файл {file_path} не найден локально И не удалось его скачать с HF. Инициализация пустой структурой.")
|
| 124 |
+
# Не создаем пустой файл здесь!
|
| 125 |
+
return {'products': [], 'categories': []}
|
| 126 |
except json.JSONDecodeError:
|
| 127 |
+
logging.error(f"Ошибка декодирования JSON в локальном {file_path}. Файл может быть поврежден. Возврат пустой структуры.")
|
| 128 |
return {'products': [], 'categories': []}
|
| 129 |
except Exception as e:
|
| 130 |
+
logging.error(f"Неизвестная ошибка при загрузке данных ({file_path}): {e}", exc_info=True)
|
| 131 |
return {'products': [], 'categories': []}
|
| 132 |
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
def load_users():
|
| 135 |
+
file_path = USERS_FILE
|
| 136 |
+
logging.info(f"Попытка загрузки данных пользователей из {file_path}...")
|
| 137 |
+
download_success = download_db_from_hf(specific_file=file_path)
|
| 138 |
+
|
| 139 |
+
if download_success:
|
| 140 |
+
logging.info(f"Скачивание {file_path} с HF успешно (или файл отсутствовал на HF). Попытка загрузки локальной версии.")
|
| 141 |
+
else:
|
| 142 |
+
logging.warning(f"Не удалось скачать {file_path} с HF после всех попыток. Попытка загрузить существующий локальный файл (если есть).")
|
| 143 |
+
|
| 144 |
try:
|
| 145 |
+
lock = get_lock(file_path)
|
| 146 |
+
with lock:
|
| 147 |
+
with open(file_path, 'r', encoding='utf-8') as file:
|
| 148 |
+
users = json.load(file)
|
| 149 |
+
logging.info(f"Данные пользователей успешно загружены из локального файла {file_path}")
|
| 150 |
+
return users if isinstance(users, dict) else {}
|
| 151 |
except FileNotFoundError:
|
| 152 |
+
logging.critical(f"КРИТИЧЕСКАЯ ОШИБКА: Файл {file_path} не найден локально И не удалось его скачать с HF. Инициализация пустым словарем.")
|
| 153 |
+
# Не создаем пустой файл здесь!
|
| 154 |
+
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
except json.JSONDecodeError:
|
| 156 |
+
logging.error(f"Ошибка декодирования JSON в локальном {file_path}. Файл может быть поврежден. Возврат пустого словаря.")
|
| 157 |
return {}
|
| 158 |
except Exception as e:
|
| 159 |
+
logging.error(f"Неизвестная ошибка при загрузке пользователей ({file_path}): {e}", exc_info=True)
|
| 160 |
return {}
|
| 161 |
|
| 162 |
+
# --- Modified Upload Function with Safety Check ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
def upload_db_to_hf(specific_file=None):
|
| 164 |
if not HF_TOKEN_WRITE:
|
| 165 |
logging.warning("Переменная окружения HF_TOKEN (для записи) не установлена. Загрузка на Hugging Face пропущена.")
|
|
|
|
| 171 |
|
| 172 |
for file_name in files_to_upload:
|
| 173 |
if os.path.exists(file_name):
|
| 174 |
+
# --- Safety Check ---
|
| 175 |
try:
|
| 176 |
+
lock = get_lock(file_name)
|
| 177 |
+
with lock: # Блокируем чтение на время проверки
|
| 178 |
+
with open(file_name, 'r', encoding='utf-8') as f:
|
| 179 |
+
content_check = json.load(f)
|
| 180 |
+
|
| 181 |
+
if file_name == DATA_FILE and content_check == {'products': [], 'categories': []}:
|
| 182 |
+
logging.warning(f"ПРЕДОТВРАЩЕНИЕ ЗАГРУЗКИ: Локальный файл {file_name} пуст (содержит только {'{products: [], categories: []}'}). Загрузка на HF пропущена.")
|
| 183 |
+
continue # Пропустить этот файл
|
| 184 |
+
if file_name == USERS_FILE and content_check == {}:
|
| 185 |
+
logging.warning(f"ПРЕДОТВРАЩЕНИЕ ЗАГРУЗКИ: Локальный файл {file_name} пуст (содержит только {'{}'}). Загрузка на HF пропущена.")
|
| 186 |
+
continue # Пропустить этот файл
|
| 187 |
+
|
| 188 |
+
except json.JSONDecodeError:
|
| 189 |
+
logging.error(f"Ошибка чтения JSON в локальном файле {file_name} перед загрузкой. Загрузка на HF для этого файла пропущена.")
|
| 190 |
+
continue
|
| 191 |
+
except Exception as e:
|
| 192 |
+
logging.error(f"Ошибка при проверке файла {file_name} перед загрузкой: {e}. Загрузка на HF для этого файла пропущена.")
|
| 193 |
+
continue
|
| 194 |
+
# --- End Safety Check ---
|
| 195 |
+
|
| 196 |
+
try:
|
| 197 |
+
lock = get_lock(file_name)
|
| 198 |
+
with lock: # Блокируем на время чтения для загрузки
|
| 199 |
+
api.upload_file(
|
| 200 |
+
path_or_fileobj=file_name,
|
| 201 |
+
path_in_repo=file_name,
|
| 202 |
+
repo_id=REPO_ID,
|
| 203 |
+
repo_type="dataset",
|
| 204 |
+
token=HF_TOKEN_WRITE,
|
| 205 |
+
commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
| 206 |
+
)
|
| 207 |
logging.info(f"Файл {file_name} успешно загружен на Hugging Face.")
|
| 208 |
except Exception as e:
|
| 209 |
logging.error(f"Ошибка при загрузке файла {file_name} на Hugging Face: {e}")
|
|
|
|
| 213 |
except Exception as e:
|
| 214 |
logging.error(f"Общая ошибка при инициализации или загрузке на Hugging Face: {e}", exc_info=True)
|
| 215 |
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
+
# --- Modified Save Functions ---
|
| 218 |
+
def save_data(data):
|
|
|
|
| 219 |
try:
|
| 220 |
+
lock = get_lock(DATA_FILE)
|
| 221 |
+
with lock:
|
| 222 |
+
with open(DATA_FILE, 'w', encoding='utf-8') as file:
|
| 223 |
+
json.dump(data, file, ensure_ascii=False, indent=4)
|
| 224 |
+
logging.info(f"Данные успешно сохранены в {DATA_FILE}")
|
| 225 |
+
# Загрузка на HF вызывается после сохранения, но с проверкой внутри upload_db_to_hf
|
| 226 |
+
upload_db_to_hf(specific_file=DATA_FILE)
|
| 227 |
+
except filelock.Timeout:
|
| 228 |
+
logging.error(f"Не удалось получить блокировку для сохранения файла {DATA_FILE}. Сохранение и загрузка на HF пропущены.")
|
| 229 |
+
flash("Внимание: Не удалось сохранить изменения из-за блокировки файла. Попробуйте позже.", "error")
|
| 230 |
+
except Exception as e:
|
| 231 |
+
logging.error(f"Ошибка при сохранении данных в {DATA_FILE}: {e}", exc_info=True)
|
| 232 |
+
|
| 233 |
+
def save_users(users):
|
| 234 |
+
try:
|
| 235 |
+
lock = get_lock(USERS_FILE)
|
| 236 |
+
with lock:
|
| 237 |
+
with open(USERS_FILE, 'w', encoding='utf-8') as file:
|
| 238 |
+
json.dump(users, file, ensure_ascii=False, indent=4)
|
| 239 |
+
logging.info(f"Данные пользователей успешно сохранены в {USERS_FILE}")
|
| 240 |
+
# Загрузка на HF вызывается после сохранения, но с проверкой внутри upload_db_to_hf
|
| 241 |
+
upload_db_to_hf(specific_file=USERS_FILE)
|
| 242 |
+
except filelock.Timeout:
|
| 243 |
+
logging.error(f"Не удалось получить блокировку для сохранения файла {USERS_FILE}. Сохранение и загрузка на HF пропущены.")
|
| 244 |
+
flash("Внимание: Не удалось сохранить изменения пользователя из-за блокировки файла. Попробуйте позже.", "error")
|
| 245 |
except Exception as e:
|
| 246 |
+
logging.error(f"Ошибка при сохранении данных пользователей в {USERS_FILE}: {e}", exc_info=True)
|
| 247 |
|
| 248 |
|
| 249 |
+
# --- Periodic Backup ---
|
| 250 |
def periodic_backup():
|
| 251 |
backup_interval = 1800
|
| 252 |
logging.info(f"Настройка периодического резервного копирования каждые {backup_interval} секунд.")
|
| 253 |
while True:
|
| 254 |
time.sleep(backup_interval)
|
| 255 |
logging.info("Запуск периодического резервного копирования...")
|
| 256 |
+
# upload_db_to_hf уже содержит проверку на пустые файлы
|
| 257 |
upload_db_to_hf()
|
| 258 |
logging.info("Периодическое резервное копирование завершено.")
|
| 259 |
|
| 260 |
|
| 261 |
+
# --- Flask Routes (Без изменений в HTML/JS, только вызовы функций) ---
|
| 262 |
+
|
| 263 |
@app.route('/')
|
| 264 |
def catalog():
|
| 265 |
data = load_data()
|
|
|
|
| 377 |
body.dark-mode .no-results-message { color: #8aa39a; }
|
| 378 |
.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); }
|
| 379 |
.product { position: relative; }
|
| 380 |
+
.flash-messages { list-style: none; padding: 0; margin: 0 0 20px 0; }
|
| 381 |
+
.flash-messages li { padding: 10px 15px; margin-bottom: 10px; border-radius: 6px; font-size: 0.9rem;}
|
| 382 |
+
.flash-messages .success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;}
|
| 383 |
+
.flash-messages .error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;}
|
| 384 |
+
.flash-messages .warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
|
| 385 |
</style>
|
| 386 |
</head>
|
| 387 |
<body>
|
|
|
|
| 401 |
</button>
|
| 402 |
</div>
|
| 403 |
|
| 404 |
+
{% with messages = get_flashed_messages(with_categories=true) %}
|
| 405 |
+
{% if messages %}
|
| 406 |
+
<ul class="flash-messages">
|
| 407 |
+
{% for category, message in messages %}
|
| 408 |
+
<li class="{{ category }}">{{ message }}</li>
|
| 409 |
+
{% endfor %}
|
| 410 |
+
</ul>
|
| 411 |
+
{% endif %}
|
| 412 |
+
{% endwith %}
|
| 413 |
+
|
| 414 |
<div class="store-address">Наш адрес: {{ store_address }}</div>
|
| 415 |
|
| 416 |
<div class="filters-container">
|
|
|
|
| 555 |
window.location.reload();
|
| 556 |
} else {
|
| 557 |
response.text().then(text => console.log(`Auto-login failed: ${response.status} ${text}`));
|
| 558 |
+
localStorage.removeItem('soolaUser'); // Remove invalid stored user
|
| 559 |
}
|
| 560 |
})
|
| 561 |
.catch(error => {
|
|
|
|
| 652 |
} else {
|
| 653 |
colorSelect.style.display = 'none';
|
| 654 |
if(colorLabel) colorLabel.style.display = 'none';
|
| 655 |
+
// Добавляем один пустой option, чтобы значение select было пустым
|
| 656 |
+
const option = document.createElement('option');
|
| 657 |
+
option.value = '';
|
| 658 |
+
option.text = '';
|
| 659 |
+
colorSelect.appendChild(option);
|
| 660 |
}
|
| 661 |
|
| 662 |
document.getElementById('quantityInput').value = 1;
|
|
|
|
| 788 |
return;
|
| 789 |
}
|
| 790 |
let total = 0;
|
| 791 |
+
let orderText = "🛍️ *Новый Заказ от Soola Cosmetics*\n"; // Используем \n для переноса строки в тексте
|
| 792 |
+
orderText += "----------------------------------------\n";
|
| 793 |
+
orderText += "*Детали заказа:*\n";
|
| 794 |
+
orderText += "----------------------------------------\n";
|
| 795 |
cart.forEach((item, index) => {
|
| 796 |
const itemTotal = item.price * item.quantity;
|
| 797 |
total += itemTotal;
|
| 798 |
const colorText = item.color !== 'N/A' ? ` (${item.color})` : '';
|
| 799 |
+
orderText += `${index + 1}. *${item.name}*${colorText}\n`;
|
| 800 |
+
orderText += ` Кол-во: ${item.quantity}\n`;
|
| 801 |
+
orderText += ` Цена: ${item.price.toFixed(2)} ${currencyCode}\n`;
|
| 802 |
+
orderText += ` *Сумма: ${itemTotal.toFixed(2)} ${currencyCode}*\n\n`;
|
| 803 |
});
|
| 804 |
+
orderText += "----------------------------------------\n";
|
| 805 |
+
orderText += `*ИТОГО: ${total.toFixed(2)} ${currencyCode}*\n`;
|
| 806 |
+
orderText += "----------------------------------------\n\n";
|
| 807 |
|
| 808 |
if (userInfo && userInfo.login) {
|
| 809 |
+
orderText += "*Данные клиента:*\n";
|
| 810 |
+
orderText += `Имя: ${userInfo.first_name || ''} ${userInfo.last_name || ''}\n`;
|
| 811 |
+
orderText += `Логин: ${userInfo.login}\n`;
|
| 812 |
if (userInfo.phone) {
|
| 813 |
+
orderText += `Телефон: ${userInfo.phone}\n`;
|
| 814 |
}
|
| 815 |
+
orderText += `Страна: ${userInfo.country || 'Не указана'}\n`;
|
| 816 |
+
orderText += `Город: ${userInfo.city || 'Не указан'}\n`;
|
| 817 |
} else {
|
| 818 |
+
orderText += "*Клиент не авторизован*\n";
|
| 819 |
}
|
| 820 |
+
orderText += "----------------------------------------\n\n";
|
| 821 |
|
| 822 |
const now = new Date();
|
| 823 |
const dateTimeString = now.toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
|
| 824 |
+
orderText += `Дата заказа: ${dateTimeString}\n`;
|
| 825 |
orderText += `_Сформировано автоматически_`;
|
| 826 |
|
| 827 |
+
const whatsappNumber = "996997703090"; // Убедитесь, что номер правильный
|
| 828 |
+
// Заменяем \n на %0A для URL-кодирования переносов строк
|
| 829 |
const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${encodeURIComponent(orderText)}`;
|
| 830 |
window.open(whatsappUrl, '_blank');
|
| 831 |
}
|
| 832 |
|
| 833 |
+
|
| 834 |
function filterProducts() {
|
| 835 |
const searchTerm = document.getElementById('search-input').value.toLowerCase().trim();
|
| 836 |
const activeCategoryButton = document.querySelector('.category-filter.active');
|
|
|
|
| 883 |
filterProducts();
|
| 884 |
});
|
| 885 |
});
|
| 886 |
+
filterProducts();
|
| 887 |
}
|
| 888 |
|
| 889 |
function showNotification(message, duration = 3000) {
|
|
|
|
| 993 |
{% endif %}
|
| 994 |
<p><strong>Описание:</strong><br> {{ product.get('description', 'Описание отсутствует.')|replace('\\n', '<br>')|safe }}</p>
|
| 995 |
{% set colors = product.get('colors', []) %}
|
| 996 |
+
{% set valid_colors = colors|select('ne', '')|list %}
|
| 997 |
+
{% if valid_colors|length > 0 %}
|
| 998 |
+
<p><strong>Доступные цвета/варианты:</strong> {{ valid_colors|join(', ') }}</p>
|
| 999 |
{% endif %}
|
| 1000 |
</div>
|
| 1001 |
</div>
|
|
|
|
| 1117 |
return "OK", 200
|
| 1118 |
else:
|
| 1119 |
logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}.")
|
| 1120 |
+
# Не удаляем localStorage здесь, чтобы не мешать нормальному входу
|
| 1121 |
return "Ошибка авто-входа", 400
|
| 1122 |
|
| 1123 |
@app.route('/logout')
|
|
|
|
| 1228 |
<div class="section">
|
| 1229 |
<h2><i class="fas fa-sync-alt"></i> Синхронизация с Датацентром</h2>
|
| 1230 |
<div class="sync-buttons">
|
| 1231 |
+
<form method="POST" action="{{ url_for('force_upload') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно загрузить локальные данные на сервер? Это перезапишет данные на сервере (если локальные файлы не пустые).');">
|
| 1232 |
<button type="submit" class="button" title="Загрузить локальные файлы на Hugging Face"><i class="fas fa-upload"></i> Загрузить БД</button>
|
| 1233 |
</form>
|
| 1234 |
<form method="POST" action="{{ url_for('force_download') }}" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите принудительно скачать данные с сервера? Это перезапишет ваши локальные файлы.');">
|
| 1235 |
<button type="submit" class="button download-hf-button" title="Скачать файлы (перезапишет локальные)"><i class="fas fa-download"></i> Скачать БД</button>
|
| 1236 |
</form>
|
| 1237 |
</div>
|
| 1238 |
+
<p style="font-size: 0.85rem; color: #5e6e68;">Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных (с проверкой на пустые файлы). Используйте эти кнопки для немедленной синхронизации.</p>
|
| 1239 |
</div>
|
| 1240 |
|
| 1241 |
|
|
|
|
| 1318 |
<input type="hidden" name="login" value="{{ login }}">
|
| 1319 |
<button type="submit" class="delete-button"><i class="fas fa-user-slash"></i> Удалить</button>
|
| 1320 |
</form>
|
| 1321 |
+
<!-- Кнопка редактирования пользователя (если нужна) -->
|
| 1322 |
+
<!-- <button type="button" class="button" onclick="toggleEditForm('edit-user-{{ login|replace('.', '-') }}')"><i class="fas fa-edit"></i> Редактировать</button> -->
|
| 1323 |
</div>
|
| 1324 |
+
<!-- Скрытая форма редактирования пользователя (если нужна) -->
|
| 1325 |
+
<!-- <div id="edit-user-{{ login|replace('.', '-') }}" class="edit-form-container"> ... </div> -->
|
| 1326 |
</div>
|
| 1327 |
{% endfor %}
|
| 1328 |
</div>
|
|
|
|
| 1386 |
<div class="item">
|
| 1387 |
<div style="display: flex; gap: 15px; align-items: flex-start;">
|
| 1388 |
<div class="photo-preview" style="flex-shrink: 0;">
|
| 1389 |
+
{% if product.get('photos') and product.photos|length > 0 %}
|
| 1390 |
<a href="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" target="_blank" title="Посмотреть первое фото">
|
| 1391 |
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}" alt="Фото">
|
| 1392 |
</a>
|
|
|
|
| 1410 |
<p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_code }}</p>
|
| 1411 |
<p class="description" title="{{ product.get('description', '') }}"><strong>Описание:</strong> {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}</p>
|
| 1412 |
{% set colors = product.get('colors', []) %}
|
| 1413 |
+
{% set valid_colors = colors|select('ne', '')|list %}
|
| 1414 |
+
<p><strong>Цвета/Вар-ты:</strong> {{ valid_colors|join(', ') if valid_colors|length > 0 else 'Нет' }}</p>
|
| 1415 |
{% if product.get('photos') and product['photos']|length > 1 %}
|
| 1416 |
<p style="font-size: 0.8rem; color: #5e6e68;">(Всего фото: {{ product['photos']|length }})</p>
|
| 1417 |
{% endif %}
|
|
|
|
| 1447 |
</select>
|
| 1448 |
<label>Заменить фотографии (выберите новые файлы, до 10 шт.):</label>
|
| 1449 |
<input type="file" name="photos" accept="image/*" multiple>
|
| 1450 |
+
{% if product.get('photos') and product.photos|length > 0 %}
|
| 1451 |
<p style="font-size: 0.85rem; margin-top: 5px;">Текущие фото:</p>
|
| 1452 |
<div class="photo-preview">
|
| 1453 |
{% for photo in product['photos'] %}
|
|
|
|
| 1458 |
<label>Цвета/Варианты:</label>
|
| 1459 |
<div id="edit-color-inputs-{{ loop.index0 }}">
|
| 1460 |
{% set current_colors = product.get('colors', []) %}
|
| 1461 |
+
{% set valid_current_colors = current_colors|select('ne', '')|list %}
|
| 1462 |
+
{% if valid_current_colors|length > 0 %}
|
| 1463 |
+
{% for color in valid_current_colors %}
|
| 1464 |
<div class="color-input-group">
|
| 1465 |
<input type="text" name="colors" value="{{ color }}">
|
| 1466 |
<button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
|
| 1467 |
</div>
|
|
|
|
| 1468 |
{% endfor %}
|
| 1469 |
{% else %}
|
| 1470 |
<div class="color-input-group">
|
|
|
|
| 1526 |
const group = button.closest('.color-input-group');
|
| 1527 |
if (group) {
|
| 1528 |
const container = group.parentNode;
|
|
|
|
|
|
|
| 1529 |
group.remove();
|
| 1530 |
+
// Если удалили последний инпут, добавим новый пустой
|
| 1531 |
if (container && container.children.length === 0) {
|
| 1532 |
const placeholderGroup = document.createElement('div');
|
| 1533 |
placeholderGroup.className = 'color-input-group';
|
|
|
|
| 1571 |
flash("Название категории не может быть пустым.", 'error')
|
| 1572 |
else:
|
| 1573 |
logging.warning(f"Категория '{category_name}' уже существует.")
|
| 1574 |
+
flash(f"Категория '{category_name}' уже существует.", 'warning')
|
| 1575 |
|
| 1576 |
elif action == 'delete_category':
|
| 1577 |
category_to_delete = request.form.get('category_name')
|
|
|
|
| 1613 |
return redirect(url_for('admin'))
|
| 1614 |
|
| 1615 |
photos_list = []
|
| 1616 |
+
if photos_files and any(f.filename for f in photos_files):
|
| 1617 |
+
if not HF_TOKEN_WRITE:
|
| 1618 |
+
flash("HF_TOKEN для записи не установлен. Фотографии не будут загружены.", 'warning')
|
| 1619 |
+
logging.warning("HF_TOKEN_WRITE не установлен, пропуск загрузки фото.")
|
| 1620 |
+
else:
|
| 1621 |
+
uploads_dir = 'uploads_temp'
|
| 1622 |
+
os.makedirs(uploads_dir, exist_ok=True)
|
| 1623 |
+
api = HfApi()
|
| 1624 |
+
photo_limit = 10
|
| 1625 |
+
uploaded_count = 0
|
| 1626 |
+
for photo in photos_files:
|
| 1627 |
+
if uploaded_count >= photo_limit:
|
| 1628 |
+
logging.warning(f"Достигнут лимит фото ({photo_limit}), остальные фото проигнорированы.")
|
| 1629 |
+
flash(f"Загружено только первые {photo_limit} фото.", "warning")
|
| 1630 |
+
break
|
| 1631 |
+
if photo and photo.filename:
|
| 1632 |
+
try:
|
| 1633 |
+
ext = os.path.splitext(photo.filename)[1].lower()
|
| 1634 |
+
if ext not in ['.png', '.jpg', '.jpeg', '.gif', '.webp']:
|
| 1635 |
+
logging.warning(f"Пропущен файл с неподдерживаемым расширением: {photo.filename}")
|
| 1636 |
+
flash(f"Файл {photo.filename} имеет неподдерживаемый тип.", 'warning')
|
| 1637 |
+
continue
|
| 1638 |
+
|
| 1639 |
+
safe_original_filename = secure_filename(os.path.splitext(photo.filename)[0])
|
| 1640 |
+
photo_filename = f"{name.replace(' ','_')}_{safe_original_filename}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
|
| 1641 |
+
temp_path = os.path.join(uploads_dir, photo_filename)
|
| 1642 |
+
photo.save(temp_path)
|
| 1643 |
+
logging.info(f"Загрузка фото {photo_filename} на HF для товара {name}...")
|
| 1644 |
+
|
| 1645 |
+
lock = get_lock(os.path.join("photos", photo_filename)) # Lock for HF upload path
|
| 1646 |
+
with lock:
|
| 1647 |
+
api.upload_file(
|
| 1648 |
+
path_or_fileobj=temp_path,
|
| 1649 |
+
path_in_repo=f"photos/{photo_filename}",
|
| 1650 |
+
repo_id=REPO_ID,
|
| 1651 |
+
repo_type="dataset",
|
| 1652 |
+
token=HF_TOKEN_WRITE,
|
| 1653 |
+
commit_message=f"Add photo for product {name}"
|
| 1654 |
+
)
|
| 1655 |
+
photos_list.append(photo_filename)
|
| 1656 |
+
logging.info(f"Фото {photo_filename} успешно загружено.")
|
| 1657 |
+
os.remove(temp_path)
|
| 1658 |
+
uploaded_count += 1
|
| 1659 |
+
except Exception as e:
|
| 1660 |
+
logging.error(f"Ошибка загрузки фото {photo.filename} на HF: {e}", exc_info=True)
|
| 1661 |
+
flash(f"Ошибка при загрузке фото {photo.filename}.", 'error')
|
| 1662 |
+
elif photo and not photo.filename:
|
| 1663 |
+
logging.warning("Получен пустой объект файла фото при добавлении товара.")
|
| 1664 |
+
try:
|
| 1665 |
+
if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
|
| 1666 |
+
os.rmdir(uploads_dir)
|
| 1667 |
+
except OSError as e:
|
| 1668 |
+
logging.warning(f"Не удалось удалить временную папку {uploads_dir}: {e}")
|
| 1669 |
|
| 1670 |
|
| 1671 |
new_product = {
|
|
|
|
| 1674 |
'photos': photos_list, 'colors': colors,
|
| 1675 |
'in_stock': in_stock, 'is_top': is_top
|
| 1676 |
}
|
| 1677 |
+
# Добавляем в начало списка, если Топ, иначе в конец (для примерной сортировки при добавлении)
|
| 1678 |
+
if is_top:
|
| 1679 |
+
products.insert(0, new_product)
|
| 1680 |
+
else:
|
| 1681 |
+
products.append(new_product)
|
| 1682 |
+
|
| 1683 |
|
| 1684 |
save_data(data)
|
| 1685 |
logging.info(f"Товар '{name}' добавлен.")
|
|
|
|
| 1693 |
|
| 1694 |
try:
|
| 1695 |
index = int(index_str)
|
| 1696 |
+
original_product_list = data.get('products', []) # Работаем с оригинальным списком
|
|
|
|
|
|
|
| 1697 |
if not (0 <= index < len(original_product_list)): raise IndexError("Индекс вне диапазона")
|
| 1698 |
product_to_edit = original_product_list[index]
|
| 1699 |
original_name = product_to_edit.get('name', 'N/A')
|
| 1700 |
|
| 1701 |
except (ValueError, IndexError):
|
| 1702 |
flash(f"Ошибка редактирова��ия: неверный индекс товара '{index_str}'.", 'error')
|
| 1703 |
+
logging.error(f"Ошибка редактирования: неверный индекс {index_str}. Всего товаров: {len(original_product_list)}")
|
| 1704 |
return redirect(url_for('admin'))
|
| 1705 |
|
| 1706 |
product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip()
|
|
|
|
| 1722 |
flash(f"Неверный формат цены для товара '{original_name}'. Цена не изменена.", 'warning')
|
| 1723 |
|
| 1724 |
photos_files = request.files.getlist('photos')
|
| 1725 |
+
if photos_files and any(f.filename for f in photos_files):
|
| 1726 |
+
if not HF_TOKEN_WRITE:
|
| 1727 |
+
flash("HF_TOKEN для записи не установлен. Фотографии не будут обновлены.", 'warning')
|
| 1728 |
+
logging.warning("HF_TOKEN_WRITE не установлен, пропуск обновления фото.")
|
| 1729 |
+
else:
|
| 1730 |
+
uploads_dir = 'uploads_temp'
|
| 1731 |
+
os.makedirs(uploads_dir, exist_ok=True)
|
| 1732 |
+
api = HfApi()
|
| 1733 |
+
new_photos_list = []
|
| 1734 |
+
photo_limit = 10
|
| 1735 |
+
uploaded_count = 0
|
| 1736 |
+
logging.info(f"Загрузка новых фото для товара {product_to_edit['name']}...")
|
| 1737 |
+
for photo in photos_files:
|
| 1738 |
+
if uploaded_count >= photo_limit:
|
| 1739 |
+
logging.warning(f"Достигнут лимит фото ({photo_limit}), остальные фото проигнорированы.")
|
| 1740 |
+
flash(f"Загружено только первые {photo_limit} фото.", "warning")
|
| 1741 |
+
break
|
| 1742 |
+
if photo and photo.filename:
|
| 1743 |
+
try:
|
| 1744 |
+
ext = os.path.splitext(photo.filename)[1].lower()
|
| 1745 |
+
if ext not in ['.png', '.jpg', '.jpeg', '.gif', '.webp']:
|
| 1746 |
+
logging.warning(f"Пропущен файл с неподдерживаемым расширением при редактировании: {photo.filename}")
|
| 1747 |
+
flash(f"Файл {photo.filename} имеет неподдерживаемый тип и был пропущен.", 'warning')
|
| 1748 |
+
continue
|
| 1749 |
+
|
| 1750 |
+
safe_original_filename = secure_filename(os.path.splitext(photo.filename)[0])
|
| 1751 |
+
photo_filename = f"{product_to_edit['name'].replace(' ','_')}_{safe_original_filename}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}"
|
| 1752 |
+
temp_path = os.path.join(uploads_dir, photo_filename)
|
| 1753 |
+
photo.save(temp_path)
|
| 1754 |
+
logging.info(f"Загрузка нового фото {photo_filename} на HF...")
|
| 1755 |
+
|
| 1756 |
+
lock = get_lock(os.path.join("photos", photo_filename)) # Lock for HF upload path
|
| 1757 |
+
with lock:
|
| 1758 |
+
api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}",
|
| 1759 |
+
repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE,
|
| 1760 |
+
commit_message=f"Update photo for product {product_to_edit['name']}")
|
| 1761 |
+
new_photos_list.append(photo_filename)
|
| 1762 |
+
logging.info(f"Новое фото {photo_filename} успешно загружено.")
|
| 1763 |
+
os.remove(temp_path)
|
| 1764 |
+
uploaded_count += 1
|
| 1765 |
+
except Exception as e:
|
| 1766 |
+
logging.error(f"Ошибка загрузки нового фото {photo.filename}: {e}", exc_info=True)
|
| 1767 |
+
flash(f"Ошибка при загрузке нового фото {photo.filename}.", 'error')
|
| 1768 |
+
try:
|
| 1769 |
+
if os.path.exists(uploads_dir) and not os.listdir(uploads_dir):
|
| 1770 |
+
os.rmdir(uploads_dir)
|
| 1771 |
+
except OSError as e:
|
| 1772 |
+
logging.warning(f"Не удалось удалить временную папку {uploads_dir}: {e}")
|
| 1773 |
+
|
| 1774 |
+
if new_photos_list:
|
| 1775 |
+
logging.info(f"Список фото для товара {product_to_edit['name']} обновлен.")
|
| 1776 |
+
old_photos = product_to_edit.get('photos', [])
|
| 1777 |
+
if old_photos:
|
| 1778 |
+
logging.info(f"Попытка удаления старых фото: {old_photos}")
|
| 1779 |
+
try:
|
| 1780 |
+
paths_to_delete = [f"photos/{p}" for p in old_photos]
|
| 1781 |
+
# Lock deletion? Might be complex. HF Hub handles concurrent ops.
|
| 1782 |
+
api.delete_files(
|
| 1783 |
+
repo_id=REPO_ID,
|
| 1784 |
+
paths_in_repo=paths_to_delete,
|
| 1785 |
+
repo_type="dataset",
|
| 1786 |
+
token=HF_TOKEN_WRITE,
|
| 1787 |
+
commit_message=f"Delete old photos for product {product_to_edit['name']}"
|
| 1788 |
+
)
|
| 1789 |
+
logging.info(f"Старые фото для товара {product_to_edit['name']} удалены с HF.")
|
| 1790 |
+
except Exception as e:
|
| 1791 |
+
# Log error but don't block the main update
|
| 1792 |
+
logging.error(f"Ошибка при удалении старых фото {old_photos} с HF: {e}", exc_info=True)
|
| 1793 |
+
flash("Не удалось удалить старые фотографии с сервера (возможно, их уже нет).", "warning")
|
| 1794 |
+
product_to_edit['photos'] = new_photos_list
|
| 1795 |
+
flash("Фотографии товара успешно обновлены.", "success")
|
| 1796 |
+
elif uploaded_count == 0 and any(f.filename for f in photos_files):
|
| 1797 |
+
# This case means files were selected, but none were valid/uploaded
|
| 1798 |
+
flash("Не удалось загрузить ни одну из выбранных новых фотографий (проверьте тип файлов).", "warning")
|
| 1799 |
|
| 1800 |
|
| 1801 |
save_data(data)
|
|
|
|
| 1810 |
return redirect(url_for('admin'))
|
| 1811 |
try:
|
| 1812 |
index = int(index_str)
|
| 1813 |
+
original_product_list = data.get('products', []) # Работаем с оригинальным списком
|
| 1814 |
if not (0 <= index < len(original_product_list)): raise IndexError("Индекс вне диапазона")
|
| 1815 |
+
|
| 1816 |
deleted_product = original_product_list.pop(index)
|
| 1817 |
product_name = deleted_product.get('name', 'N/A')
|
| 1818 |
|
| 1819 |
photos_to_delete = deleted_product.get('photos', [])
|
| 1820 |
+
if photos_to_delete:
|
| 1821 |
+
if not HF_TOKEN_WRITE:
|
| 1822 |
+
flash(f"HF_TOKEN для записи не установлен. Фотографии товара '{product_name}' не будут удалены с сервера.", 'warning')
|
| 1823 |
+
logging.warning(f"HF_TOKEN_WRITE не установлен, пропуск удаления фото для {product_name}.")
|
| 1824 |
+
else:
|
| 1825 |
+
logging.info(f"Попытка удаления фото товара '{product_name}' с HF: {photos_to_delete}")
|
| 1826 |
+
try:
|
| 1827 |
+
api = HfApi()
|
| 1828 |
+
paths_to_delete = [f"photos/{p}" for p in photos_to_delete]
|
| 1829 |
+
api.delete_files(
|
| 1830 |
+
repo_id=REPO_ID,
|
| 1831 |
+
paths_in_repo=paths_to_delete,
|
| 1832 |
+
repo_type="dataset",
|
| 1833 |
+
token=HF_TOKEN_WRITE,
|
| 1834 |
+
commit_message=f"Delete photos for deleted product {product_name}"
|
| 1835 |
+
)
|
| 1836 |
+
logging.info(f"Фото товара '{product_name}' удалены с HF.")
|
| 1837 |
+
except Exception as e:
|
| 1838 |
+
# Log error but don't block the main delete
|
| 1839 |
+
logging.error(f"Ошибка при удалении фото {photos_to_delete} для товара '{product_name}' с HF: {e}", exc_info=True)
|
| 1840 |
+
flash(f"Не удалось удалить фото для товара '{product_name}' с сервера (возможно, ��х уже нет).", "warning")
|
| 1841 |
|
| 1842 |
save_data(data)
|
| 1843 |
logging.info(f"Товар '{product_name}' (индекс {index}) удален.")
|
| 1844 |
flash(f"Товар '{product_name}' удален.", 'success')
|
| 1845 |
except (ValueError, IndexError):
|
| 1846 |
flash(f"Ошибка удаления: неверный индекс товара '{index_str}'.", 'error')
|
| 1847 |
+
logging.error(f"Ошибка удаления: неверный индекс {index_str}. Всего товаров: {len(original_product_list)}")
|
| 1848 |
|
| 1849 |
|
| 1850 |
elif action == 'add_user':
|
| 1851 |
login = request.form.get('login', '').strip()
|
| 1852 |
+
password = request.form.get('password', '').strip() # Пароль не очищается от пробелов по краям намеренно
|
| 1853 |
first_name = request.form.get('first_name', '').strip()
|
| 1854 |
last_name = request.form.get('last_name', '').strip()
|
| 1855 |
phone = request.form.get('phone', '').strip()
|
|
|
|
| 1890 |
|
| 1891 |
return redirect(url_for('admin'))
|
| 1892 |
|
| 1893 |
+
except filelock.Timeout:
|
| 1894 |
+
logging.error(f"Таймаут блокировки файла при обработке действия '{action}' в админ-панели.")
|
| 1895 |
+
flash("Не удалось выполнить действие из-за конфликта доступа к файлу. Попробуйте снова.", 'error')
|
| 1896 |
+
return redirect(url_for('admin'))
|
| 1897 |
except Exception as e:
|
| 1898 |
logging.error(f"Ошибка при обработке действия '{action}' в админ-панели: {e}", exc_info=True)
|
| 1899 |
flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error')
|
| 1900 |
return redirect(url_for('admin'))
|
| 1901 |
|
| 1902 |
+
# Передаем оригинальный, несортированный список продуктов для сохранения индексов
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1903 |
original_product_list = data.get('products', [])
|
|
|
|
| 1904 |
categories.sort()
|
| 1905 |
sorted_users = dict(sorted(users.items()))
|
| 1906 |
|
| 1907 |
return render_template_string(
|
| 1908 |
ADMIN_TEMPLATE,
|
| 1909 |
+
products=original_product_list,
|
| 1910 |
categories=categories,
|
| 1911 |
users=sorted_users,
|
| 1912 |
repo_id=REPO_ID,
|
|
|
|
| 1917 |
def force_upload():
|
| 1918 |
logging.info("Запущена принудительная загрузка данных на Hugging Face...")
|
| 1919 |
try:
|
| 1920 |
+
# upload_db_to_hf уже содержит проверку на пустые файлы
|
| 1921 |
upload_db_to_hf()
|
| 1922 |
+
flash("Попытка загрузки данных на Hugging Face инициирована (пустые файлы будут пропущены).", 'success')
|
| 1923 |
except Exception as e:
|
| 1924 |
logging.error(f"Ошибка при принудительной загрузке: {e}", exc_info=True)
|
| 1925 |
flash(f"Ошибка при загрузке на Hugging Face: {e}", 'error')
|
|
|
|
| 1929 |
def force_download():
|
| 1930 |
logging.info("Запущено принудительное скачивание данных с Hugging Face...")
|
| 1931 |
try:
|
| 1932 |
+
success = download_db_from_hf()
|
| 1933 |
+
if success:
|
| 1934 |
+
flash("Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.", 'success')
|
| 1935 |
+
# Перезагружаем данные в память после успешного скачивания
|
| 1936 |
+
load_data()
|
| 1937 |
+
load_users()
|
| 1938 |
+
else:
|
| 1939 |
+
flash("Не удалось скачать данные с Hugging Face после нескольких попыток. Локальные файлы не изменены.", 'error')
|
| 1940 |
except Exception as e:
|
| 1941 |
logging.error(f"Ошибка при принудительном скачивании: {e}", exc_info=True)
|
| 1942 |
flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error')
|
|
|
|
| 1944 |
|
| 1945 |
|
| 1946 |
if __name__ == '__main__':
|
| 1947 |
+
# Первоначальная загрузка при старте
|
| 1948 |
load_data()
|
| 1949 |
load_users()
|
| 1950 |
|
|
|
|
| 1953 |
backup_thread.start()
|
| 1954 |
logging.info("Поток периодического резервного копирования запущен.")
|
| 1955 |
else:
|
| 1956 |
+
logging.warning("Периодическое резервное копирование НЕ будет запущено (HF_TOKEN_WRITE не установлен).")
|
| 1957 |
|
| 1958 |
port = int(os.environ.get('PORT', 7860))
|
| 1959 |
logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}")
|
| 1960 |
+
# debug=False важно для продакшена и работы с потоками/блокировками
|
| 1961 |
app.run(debug=False, host='0.0.0.0', port=port)
|
|
|