Shveiauto commited on
Commit
2cab80a
·
verified ·
1 Parent(s): e43577d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +419 -292
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
- download_db_from_hf()
38
- with open(DATA_FILE, 'r', encoding='utf-8') as file:
39
- data = json.load(file)
40
- logging.info(f"Данные успешно загружены из {DATA_FILE}")
41
- if not isinstance(data, dict):
42
- logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.")
43
- return {'products': [], 'categories': []}
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
- except json.JSONDecodeError:
70
- logging.error(f"Ошибка декодирования JSON в {DATA_FILE} после попытки скачивания.")
71
- return {'products': [], 'categories': []}
72
- except Exception as e:
73
- logging.error(f"Неизвестная ошибка при загрузке данных после попытки скачивания: {e}")
74
- return {'products': [], 'categories': []}
 
75
  except json.JSONDecodeError:
76
- logging.error(f"Ошибка декодирования JSON в локальном {DATA_FILE}. Файл может быть поврежден. Возврат пустой структуры.")
77
  return {'products': [], 'categories': []}
78
  except Exception as e:
79
- logging.error(f"Неизвестная ошибка при загрузке данных ({DATA_FILE}): {e}", exc_info=True)
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
- with open(USERS_FILE, 'r', encoding='utf-8') as file:
95
- users = json.load(file)
96
- logging.info(f"Данные пользователей успешно загружены из {USERS_FILE}")
97
- return users if isinstance(users, dict) else {}
 
 
98
  except FileNotFoundError:
99
- logging.warning(f"Локальный файл {USERS_FILE} не найден. Попытка скачать с HF.")
100
- try:
101
- download_db_from_hf(specific_file=USERS_FILE)
102
- with open(USERS_FILE, 'r', encoding='utf-8') as file:
103
- users = json.load(file)
104
- logging.info(f"Данные пользователей загружены из {USERS_FILE} после скачивания.")
105
- return users if isinstance(users, dict) else {}
106
- except (FileNotFoundError, RepositoryNotFoundError):
107
- logging.warning(f"Файл {USERS_FILE} не найден локально и в репозитории HF. Создание пустого файла.")
108
- with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f)
109
- return {}
110
- except json.JSONDecodeError:
111
- logging.error(f"Ошибка декодирования JSON в {USERS_FILE} после скачивания.")
112
- return {}
113
- except Exception as e:
114
- logging.error(f"Неизвестная ошибка при загрузке пользователей после скачивания: {e}", exc_info=True)
115
- return {}
116
  except json.JSONDecodeError:
117
- logging.error(f"Ошибка декодирования JSON в локальном {USERS_FILE}. Файл может быть поврежден. Возврат пустого словаря.")
118
  return {}
119
  except Exception as e:
120
- logging.error(f"Неизвестная ошибка при загрузке пользователей ({USERS_FILE}): {e}", exc_info=True)
121
  return {}
122
 
123
- def save_users(users):
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
- api.upload_file(
146
- path_or_fileobj=file_name,
147
- path_in_repo=file_name,
148
- repo_id=REPO_ID,
149
- repo_type="dataset",
150
- token=HF_TOKEN_WRITE,
151
- commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
152
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  logging.info(f"Файл {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
- files_to_download = [specific_file] if specific_file else SYNC_FILES
167
- logging.info(f"Начало скачивания файлов {files_to_download} с HF репозитория {REPO_ID}...")
168
- downloaded_files_count = 0
169
  try:
170
- for file_name in files_to_download:
171
- try:
172
- local_path = hf_hub_download(
173
- repo_id=REPO_ID,
174
- filename=file_name,
175
- repo_type="dataset",
176
- token=HF_TOKEN_READ,
177
- local_dir=".",
178
- local_dir_use_symlinks=False,
179
- force_download=True
180
- )
181
- logging.info(f"Файл {file_name} успешно скачан из Hugging Face в {local_path}.")
182
- downloaded_files_count += 1
183
- except RepositoryNotFoundError:
184
- logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.")
185
- break
186
- except Exception as e:
187
- if "404" in str(e) or isinstance(e, FileNotFoundError):
188
- logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск скачивания этого файла.")
189
- else:
190
- logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True)
191
- logging.info(f"Скачивание файлов с HF завершено. Скачано файлов: {downloaded_files_count}/{len(files_to_download)}.")
192
- except RepositoryNotFoundError:
193
- logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание не выполнено.")
 
194
  except Exception as e:
195
- logging.error(f"Общая ошибка при попытке скачивания с Hugging Face: {e}", exc_info=True)
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 += "*Детали заказа:*%0A";
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}%0A`;
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(); // Initial filter on load
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
- {% if colors and colors|select('ne', '')|list|length > 0 %}
921
- <p><strong>Доступные цвета/варианты:</strong> {{ colors|select('ne', '')|join(', ') }}</p>
 
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 минут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.</p>
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
- <p><strong>Цвета/Вар-ты:</strong> {{ colors|select('ne', '')|join(', ') if colors|select('ne', '')|list|length > 0 else 'Нет' }}</p>
 
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
- {% if current_colors and current_colors|select('ne', '')|list|length > 0 %}
1379
- {% for color in current_colors %}
1380
- {% if color.strip() %}
1381
  <div class="color-input-group">
1382
  <input type="text" name="colors" value="{{ color }}">
1383
  <button type="button" class="remove-color-btn" onclick="removeColorInput(this)"><i class="fas fa-times"></i></button>
1384
  </div>
1385
- {% endif %}
1386
  {% endfor %}
1387
  {% else %}
1388
  <div class="color-input-group">
@@ -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
- // Optional: If container is now empty, add a placeholder input back
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}' уже существует.", 'error')
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 HF_TOKEN_WRITE:
1537
- uploads_dir = 'uploads_temp'
1538
- os.makedirs(uploads_dir, exist_ok=True)
1539
- api = HfApi()
1540
- photo_limit = 10
1541
- uploaded_count = 0
1542
- for photo in photos_files:
1543
- if uploaded_count >= photo_limit:
1544
- logging.warning(f"Достигнут лимит фото ({photo_limit}), остальные фото проигнорированы.")
1545
- flash(f"Загружено только первые {photo_limit} фото.", "warning")
1546
- break
1547
- if photo and photo.filename:
1548
- try:
1549
- ext = os.path.splitext(photo.filename)[1]
1550
- photo_filename = secure_filename(f"{name.replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
1551
- temp_path = os.path.join(uploads_dir, photo_filename)
1552
- photo.save(temp_path)
1553
- logging.info(f"Загрузка фото {photo_filename} на HF для товара {name}...")
1554
- api.upload_file(
1555
- path_or_fileobj=temp_path,
1556
- path_in_repo=f"photos/{photo_filename}",
1557
- repo_id=REPO_ID,
1558
- repo_type="dataset",
1559
- token=HF_TOKEN_WRITE,
1560
- commit_message=f"Add photo for product {name}"
1561
- )
1562
- photos_list.append(photo_filename)
1563
- logging.info(f"Фото {photo_filename} успешно загружено.")
1564
- os.remove(temp_path)
1565
- uploaded_count += 1
1566
- except Exception as e:
1567
- logging.error(f"Ошибка загрузки фото {photo.filename} на HF: {e}", exc_info=True)
1568
- flash(f"Ошибка при загрузке фото {photo.filename}.", 'error')
1569
- elif photo and not photo.filename:
1570
- logging.warning("Получен пустой объект файла фото при добавлении товара.")
1571
- try:
1572
- if not os.listdir(uploads_dir):
1573
- os.rmdir(uploads_dir)
1574
- except OSError as e:
1575
- logging.warning(f"Не удалось удалить временную папку {uploads_dir}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- products.append(new_product)
 
 
 
 
 
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) and HF_TOKEN_WRITE:
1629
- uploads_dir = 'uploads_temp'
1630
- os.makedirs(uploads_dir, exist_ok=True)
1631
- api = HfApi()
1632
- new_photos_list = []
1633
- photo_limit = 10
1634
- uploaded_count = 0
1635
- logging.info(f"Загрузка новых фото для товара {product_to_edit['name']}...")
1636
- for photo in photos_files:
1637
- if uploaded_count >= photo_limit:
1638
- logging.warning(f"Достигнут лимит фото ({photo_limit}), остальные фото проигнорированы.")
1639
- flash(f"Загружено только первые {photo_limit} фото.", "warning")
1640
- break
1641
- if photo and photo.filename:
1642
- try:
1643
- ext = os.path.splitext(photo.filename)[1]
1644
- photo_filename = secure_filename(f"{product_to_edit['name'].replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}")
1645
- temp_path = os.path.join(uploads_dir, photo_filename)
1646
- photo.save(temp_path)
1647
- logging.info(f"Загрузка нового фото {photo_filename} на HF...")
1648
- api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}",
1649
- repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE,
1650
- commit_message=f"Update photo for product {product_to_edit['name']}")
1651
- new_photos_list.append(photo_filename)
1652
- logging.info(f"Новое фото {photo_filename} успешно загружено.")
1653
- os.remove(temp_path)
1654
- uploaded_count += 1
1655
- except Exception as e:
1656
- logging.error(f"Ошибка загрузки нового фото {photo.filename}: {e}", exc_info=True)
1657
- flash(f"Ошибка при загрузке нового фото {photo.filename}.", 'error')
1658
- try:
1659
- if not os.listdir(uploads_dir):
1660
- os.rmdir(uploads_dir)
1661
- except OSError as e:
1662
- logging.warning(f"Не удалось удалить временную папку {uploads_dir}: {e}")
1663
-
1664
- if new_photos_list:
1665
- logging.info(f"Список фото для товара {product_to_edit['name']} обновлен.")
1666
- old_photos = product_to_edit.get('photos', [])
1667
- if old_photos:
1668
- logging.info(f"Попытка удаления старых фото: {old_photos}")
1669
- try:
1670
- api.delete_files(
1671
- repo_id=REPO_ID,
1672
- paths_in_repo=[f"photos/{p}" for p in old_photos],
1673
- repo_type="dataset",
1674
- token=HF_TOKEN_WRITE,
1675
- commit_message=f"Delete old photos for product {product_to_edit['name']}"
1676
- )
1677
- logging.info(f"Старые фото для товара {product_to_edit['name']} удалены с HF.")
1678
- except Exception as e:
1679
- logging.error(f"Ошибка при удалении старых фото {old_photos} с HF: {e}", exc_info=True)
1680
- flash("Не удалось удалить старые фотографии с сервера.", "warning")
1681
- product_to_edit['photos'] = new_photos_list
1682
- flash("Фотографии товара успешно обновлены.", "success")
1683
- elif uploaded_count == 0 and any(f.filename for f in photos_files):
1684
- flash("Не удалось загрузить новые фотографии.", "error")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 and HF_TOKEN_WRITE:
1706
- logging.info(f"Попытка удаления фото товара '{product_name}' с HF: {photos_to_delete}")
1707
- try:
1708
- api = HfApi()
1709
- api.delete_files(
1710
- repo_id=REPO_ID,
1711
- paths_in_repo=[f"photos/{p}" for p in photos_to_delete],
1712
- repo_type="dataset",
1713
- token=HF_TOKEN_WRITE,
1714
- commit_message=f"Delete photos for deleted product {product_name}"
1715
- )
1716
- logging.info(f"Фото товара '{product_name}' удалены с HF.")
1717
- except Exception as e:
1718
- logging.error(f"Ошибка при удалении фото {photos_to_delete} для товара '{product_name}' с HF: {e}", exc_info=True)
1719
- flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning")
 
 
 
 
 
 
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
- # Pass the original, unsorted product list to the admin template with original indices
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, # Pass the original list to preserve indices
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("Данные успешно загружены на Hugging Face.", 'success')
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
- flash("Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.", 'success')
 
 
 
 
 
 
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("Периодическое резервное копирование НЕ будет запущено (HF_TOKEN или HF_TOKEN_WRITE не установлена).")
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)