Shveiauto commited on
Commit
546d1b4
·
verified ·
1 Parent(s): 63a8186

Update app.py

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