diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -19,6 +19,7 @@ app = Flask(__name__) app.secret_key = 'manolisa_secret_key_no_login' DATA_FILE = 'data.json' + SYNC_FILES = [DATA_FILE] REPO_ID = "Kgshop/manolisa" @@ -171,6 +172,7 @@ translations = { } } + def get_locale(): return session.get('lang', 'ru') @@ -187,19 +189,32 @@ def set_language(language): session['lang'] = language if language in translations else 'ru' return redirect(request.referrer or url_for('catalog')) + def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY): if not HF_TOKEN_READ and not HF_TOKEN_WRITE: logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.") + token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE + files_to_download = [specific_file] if specific_file else SYNC_FILES logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...") all_successful = True + for file_name in files_to_download: success = False for attempt in range(retries + 1): try: logging.info(f"Downloading {file_name} (Attempt {attempt + 1}/{retries + 1})...") - local_path = hf_hub_download(repo_id=REPO_ID, filename=file_name, repo_type="dataset", token=token_to_use, local_dir=".", local_dir_use_symlinks=False, force_download=True, resume_download=False) + local_path = hf_hub_download( + repo_id=REPO_ID, + filename=file_name, + repo_type="dataset", + token=token_to_use, + local_dir=".", + local_dir_use_symlinks=False, + force_download=True, + resume_download=False + ) logging.info(f"Successfully downloaded {file_name} to {local_path}.") success = True break @@ -225,11 +240,14 @@ def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWN logging.error(f"Network error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...") except Exception as e: logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True) + if attempt < retries: time.sleep(delay) + if not success: logging.error(f"Failed to download {file_name} after {retries + 1} attempts.") all_successful = False + logging.info(f"Download process finished. Overall success: {all_successful}") return all_successful @@ -241,10 +259,18 @@ def upload_db_to_hf(specific_file=None): api = HfApi() files_to_upload = [specific_file] if specific_file else SYNC_FILES logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...") + for file_name in files_to_upload: if os.path.exists(file_name): try: - api.upload_file(path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + api.upload_file( + path_or_fileobj=file_name, + path_in_repo=file_name, + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + ) logging.info(f"File {file_name} successfully uploaded to Hugging Face.") except Exception as e: logging.error(f"Error uploading file {file_name} to Hugging Face: {e}") @@ -263,6 +289,8 @@ def periodic_backup(): upload_db_to_hf() logging.info("Periodic backup finished.") + + def load_data(): default_data = {'products': [], 'categories': [], 'seasons': [], 'orders': {}, 'employees': [], 'exchange_rate_kzt': 450.0} try: @@ -272,8 +300,12 @@ def load_data(): if not isinstance(data, dict): logging.warning(f"Local {DATA_FILE} is not a dictionary. Attempting download.") raise FileNotFoundError - for key, default_value in default_data.items(): - if key not in data: data[key] = default_value + if 'products' not in data: data['products'] = [] + if 'categories' not in data: data['categories'] = [] + if 'seasons' not in data: data['seasons'] = [] + if 'orders' not in data: data['orders'] = {} + if 'employees' not in data: data['employees'] = [] + if 'exchange_rate_kzt' not in data: data['exchange_rate_kzt'] = 450.0 return data except FileNotFoundError: logging.warning(f"Local file {DATA_FILE} not found. Attempting download from HF.") @@ -286,8 +318,12 @@ def load_data(): if not isinstance(data, dict): logging.error(f"Downloaded {DATA_FILE} is not a dictionary. Using default.") return default_data - for key, default_value in default_data.items(): - if key not in data: data[key] = default_value + if 'products' not in data: data['products'] = [] + if 'categories' not in data: data['categories'] = [] + if 'seasons' not in data: data['seasons'] = [] + if 'orders' not in data: data['orders'] = {} + if 'employees' not in data: data['employees'] = [] + if 'exchange_rate_kzt' not in data: data['exchange_rate_kzt'] = 450.0 return data except FileNotFoundError: logging.error(f"File {DATA_FILE} still not found even after download reported success. Using default.") @@ -314,9 +350,13 @@ def save_data(data): if not isinstance(data, dict): logging.error("Attempted to save invalid data structure (not a dict). Aborting save.") return - default_data_keys = {'products': [], 'categories': [], 'seasons': [], 'orders': {}, 'employees': [], 'exchange_rate_kzt': 450.0} - for key, default_value in default_data_keys.items(): - if key not in data: data[key] = default_value + if 'products' not in data: data['products'] = [] + if 'categories' not in data: data['categories'] = [] + if 'seasons' not in data: data['seasons'] = [] + if 'orders' not in data: data['orders'] = {} + if 'employees' not in data: data['employees'] = [] + if 'exchange_rate_kzt' not in data: data['exchange_rate_kzt'] = 450.0 + with open(DATA_FILE, 'w', encoding='utf-8') as file: json.dump(data, file, ensure_ascii=False, indent=4) logging.info(f"Data successfully saved to {DATA_FILE}") @@ -324,6 +364,8 @@ def save_data(data): except Exception as e: logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True) + + CATALOG_TEMPLATE = ''' @@ -332,90 +374,95 @@ CATALOG_TEMPLATE = ''' {{ _('site_title') }} - + @@ -1202,25 +1269,19 @@ ORDER_TEMPLATE = ''' {{ item.name }}
{{ item.name }} {% if item.color != 'N/A' %}({{ item.color }}){% endif %} - {{ item.quantity }} × {{ "%.2f"|format(item.price) }} USD + {{ item.quantity }} × {{ "%.2f"|format(item.price_at_order) }} {{ order.currency }}
- {{ "%.2f"|format(item.price * item.quantity) }} USD + {{ "%.2f"|format(item.price_at_order * item.quantity) }} {{ order.currency }}
{% endfor %}
- {% if order.currency == 'KZT' and order.total_price_kzt %} -

{{ _('total_to_pay') }}: {{ order.total_price_kzt|int }} KZT

-

({{ "%.2f"|format(order.total_price_usd) }} USD по курсу {{ (order.total_price_kzt / order.total_price_usd)|round(2) }})

- {% else %} -

{{ _('total_to_pay') }}: {{ "%.2f"|format(order.total_price_usd) }} USD

- {% endif %} +

{{ _('total_to_pay') }}: {{ "%.2f"|format(order.total_price) }} {{ order.currency }}

-

{{ _('order_status') }}

{{ _('order_status_desc') }}

@@ -1237,16 +1298,19 @@ ORDER_TEMPLATE = ''' const orderId = '{{ order.id }}'; const orderUrl = `{{ request.url }}`; const whatsappNumber = "+77773616116"; + let message = `{{ _('whatsapp_greeting') }}%0A%0A`; message += `*{{ _('whatsapp_order_number') }}* ${orderId}%0A`; message += `*{{ _('whatsapp_order_link') }}* ${encodeURIComponent(orderUrl)}%0A%0A`; message += `{{ _('whatsapp_contact_me') }}`; + const whatsappUrl = `https://api.whatsapp.com/send?phone=${whatsappNumber}&text=${message}`; window.open(whatsappUrl, '_blank'); } + {% else %} -

{{ _('error') }}

+

{{ _('error') }}

{{ _('order_not_found') }}

{{ _('back_to_catalog') }} {% endif %} @@ -1288,19 +1352,34 @@ ADMIN_TEMPLATE = ''' button[type="submit"] { min-width: 120px; justify-content: center; } .delete-button { background-color: #c04c4c; } .delete-button:hover { background-color: #a03c3c; } + .add-button { background-color: #008B8B; } + .add-button:hover { background-color: #20B2AA; } .item-list { display: grid; gap: 20px; } .item { background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.07); border: 1px solid #b0e0e6; } .item p { margin: 5px 0; font-size: 0.9rem; color: #5f7f7f; } .item strong { color: #2f4f4f; } .item .description { font-size: 0.85rem; color: #708090; max-height: 60px; overflow: hidden; text-overflow: ellipsis; } .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; } + .item-actions button:not(.delete-button) { background-color: #008B8B; } + .item-actions button:not(.delete-button):hover { background-color: #20B2AA; } .edit-form-container { margin-top: 15px; padding: 20px; background: #e0ffff; border: 1px dashed #008B8B; border-radius: 6px; display: none; } details { background-color: #f8ffff; border: 1px solid #b0e0e6; border-radius: 8px; margin-bottom: 20px; } - details > summary { cursor: pointer; font-weight: 600; color: #005f6b; display: block; padding: 15px; list-style: none; position: relative; } + details > summary { cursor: pointer; font-weight: 600; color: #005f6b; display: block; padding: 15px; border-bottom: 1px solid #b0e0e6; list-style: none; position: relative; } details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; color: #008B8B; } details[open] > summary::after { transform: translateY(-50%) rotate(180deg); } + details[open] > summary { border-bottom: 1px solid #b0e0e6; } + details .form-content { padding: 20px; } + .variant-input-group { display: grid; grid-template-columns: 1fr 1fr auto; align-items: center; gap: 10px; margin-bottom: 8px; padding: 10px; border: 1px solid #afeeee; border-radius: 5px; } + .variant-input-group label { margin-top: 0; } + .variant-input-group input { margin-top: 0; } + .remove-variant-btn { background-color: #c04c4c; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; } + .remove-variant-btn:hover { background-color: #a03c3c; } + .add-variant-btn { background-color: #b0e0e6; color: #2f4f4f; } + .add-variant-btn:hover { background-color: #afeeee; } .photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #b0e0e6; object-fit: cover;} - .sync-buttons, .rate-form { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; } + .sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; } + .download-hf-button { background-color: #708090; } + .download-hf-button:hover { background-color: #5f7f7f; } .flex-container { display: flex; flex-wrap: wrap; gap: 20px; } .flex-item { flex: 1; min-width: 300px; } .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;} @@ -1311,21 +1390,19 @@ ADMIN_TEMPLATE = ''' .status-indicator.in-stock { background-color: #c6f6d5; color: #2f855a; } .status-indicator.out-of-stock { background-color: #fed7d7; color: #c53030; } .status-indicator.top-product { background-color: #feebc8; color: #9c4221; margin-left: 5px;} - .variant-block { border: 1px solid #b0e0e6; padding: 15px; border-radius: 8px; margin-bottom: 15px; background-color: #f0f8ff; } - .variant-block-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } - .variant-block-header h4 { margin: 0; color: #008B8B; }
-
- ManolisA Logo +
+

Админ-панель ManolisA

Перейти в каталог
+ {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} @@ -1335,78 +1412,125 @@ ADMIN_TEMPLATE = ''' {% endwith %}
-

Общие настройки

-
-
+

Синхронизация и Настройки

+
+ -
- + +
-
+

Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.

+ + - - - + + +
-

Категории

+

Управление категориями

- Добавить категорию + Добавить новую категорию
- + - +
-

Существующие:

- {% for category in categories %}
{{ category }}
{% else %}

Категорий нет.

{% endfor %} -
-
-
+

Существующие категории:

+ {% if categories %} +
+ {% for category in categories %} +
+ {{ category }} +
+ + + +
+
+ {% endfor %} +
+ {% else %} +

Категорий пока нет.

+ {% endif %} +
-

Сезоны

+

Управление сезонами

- Добавить сезон + Добавить новый сезон
- + - +
-

Существующие:

- {% for season in seasons %}
{{ season }}
{% else %}

Сезонов нет.

{% endfor %} + +

Существующие сезоны:

+ {% if seasons %} +
+ {% for season in seasons %} +
+ {{ season }} +
+ + + +
+
+ {% endfor %} +
+ {% else %} +

Сезонов пока нет.

+ {% endif %}
-

Сотрудники

+

Управление сотрудниками

- Добавить сотрудника + Добавить нового сотрудника
- + - +
-

Список:

- {% for employee in employees %}
{{ employee }}
{% else %}

Сотрудников нет.

{% endfor %} + +

Список сотрудников:

+ {% if employees %} +
+ {% for employee in employees %} +
+ {{ employee }} +
+ + + +
+
+ {% endfor %} +
+ {% else %} +

Сотрудников пока нет.

+ {% endif %}
@@ -1418,7 +1542,7 @@ ADMIN_TEMPLATE = '''
- + @@ -1427,17 +1551,45 @@ ADMIN_TEMPLATE = ''' - + - - -

Цвета/Варианты и Фото

-
- -
-
-
- + + +
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+ + +
+
+ + +
+
+
@@ -1449,10 +1601,10 @@ ADMIN_TEMPLATE = '''
- {% set first_variant = product.get('variants', [])[0] if product.get('variants') else none %} - {% if first_variant and first_variant.get('photos') %} - - Фото + {% set first_photo = (product.get('variants', [{}]))[0].get('photo') %} + {% if first_photo %} + + Фото {% else %} Нет фото @@ -1460,59 +1612,100 @@ ADMIN_TEMPLATE = '''

- {{ product.name }} - {% if product.get('in_stock', True) %}В наличии{% else %}Нет в наличии{% endif %} - {% if product.get('is_top', False) %} Топ{% endif %} + {{ product['name'] }} + {% if product.get('in_stock', True) %} + В наличии + {% else %} + Нет в наличии + {% endif %} + {% if product.get('is_top', False) %} + Топ + {% endif %}

-

Категория: {{ product.get('category', 'Без категории') }} | Сезон: {{ product.get('season', 'Без сезона') }}

-

Цена: {{ "%.2f"|format(product.price) }} USD | Шт. в линейке: {{ product.get('items_per_line', 'N/A') }}

-

Цвета: {{ product.get('variants', [])|map(attribute='color')|join(', ') }}

+

Категория: {{ product.get('category', 'Без категории') }}

+

Сезон: {{ product.get('season', 'Без сезона') }}

+

Цена за линейку: {{ "%.2f"|format(product['price']) }} USD

+

Шт. в линейке: {{ product.get('items_per_line', 'N/A') }}

+

Описание: {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}

+ {% set colors = product.get('variants', []) | map(attribute='color') | list %} +

Цвета/Вар-ты: {{ colors|join(', ') if colors else 'Нет' }} ({{ colors|length }} шт.)

+
-
- + + +
+
-

Редактирование: {{ product.name }}

-
- - - - - - - +

Редактирование: {{ product['name'] }}

+ + + + + + + + + + + + + + + -

Цвета/Варианты и Фото

+
- {% for variant in product.get('variants', []) %} -
-
-

Вариант {{ loop.index }}

- + {% for variant in product.get('variants', []) %} +
+
+ + +
+
+ + + + {% if variant.photo %} +
+ Текущее фото +
+ {% endif %}
- - - - - {% if variant.get('photos') %} -

Текущие фото:

-
{% for photo in variant.photos %}{% endfor %}
- - {% endif %} +
- {% endfor %} + {% endfor %} +
+ + + +
+
+ + +
+
+ +
- -
-
-
- - +
+ +
{% endfor %} @@ -1521,6 +1714,7 @@ ADMIN_TEMPLATE = '''

Товаров пока нет.

{% endif %}
+
''' + + @app.route('/') def catalog(): data = load_data() @@ -1569,6 +1779,10 @@ def catalog(): if 'id' not in product or not product['id']: product['id'] = str(uuid.uuid4()) needs_save = True + if 'variants' not in product: + product['variants'] = [] + needs_save = True + if needs_save: data['products'] = all_products save_data(data) @@ -1582,9 +1796,9 @@ def catalog(): categories=categories, seasons=seasons, employees=employees, + exchange_rate_kzt=exchange_rate_kzt, repo_id=REPO_ID, - store_address=STORE_ADDRESS, - exchange_rate_kzt=exchange_rate_kzt + store_address=STORE_ADDRESS ) @app.route('/product/') @@ -1606,20 +1820,20 @@ def product_detail(index): return render_template_string( PRODUCT_DETAIL_TEMPLATE, product=product, - repo_id=REPO_ID, - index=index + repo_id=REPO_ID ) @app.route('/create_order', methods=['POST']) def create_order(): order_data = request.get_json() + if not order_data or 'cart' not in order_data or not order_data['cart']: - return jsonify({"error": "Корзина пуста."}), 400 + return jsonify({"error": "Корзина пуста или не передана."}), 400 cart_items = order_data['cart'] employee_name = order_data.get('employee', 'Онлайн') currency = order_data.get('currency', 'USD') - exchange_rate_kzt = float(order_data.get('exchange_rate_kzt', 450.0)) + exchange_rate = order_data.get('exchange_rate', 1) total_price_usd = 0 processed_cart = [] @@ -1627,24 +1841,35 @@ def create_order(): try: price_usd = float(item['price']) quantity = int(item['quantity']) + if price_usd < 0 or quantity <= 0: + raise ValueError("Invalid price or quantity") + total_price_usd += price_usd * quantity + processed_cart.append({ - "name": item['name'], "price": price_usd, "quantity": quantity, - "color": item.get('color', 'N/A'), "photo": item.get('photo'), + "name": item['name'], + "price_usd": price_usd, + "price_at_order": round(price_usd * exchange_rate, 2), + "quantity": quantity, + "color": item.get('color', 'N/A'), + "photo": item.get('photo'), "items_per_line": item.get('items_per_line'), "photo_url": f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/photos/{item['photo']}" if item.get('photo') else "https://via.placeholder.com/70x70.png?text=N/A" }) except (ValueError, TypeError) as e: - return jsonify({"error": "Неверная цена или количество."}), 400 + return jsonify({"error": "Неверная цена или количество в товаре."}), 400 + + order_id = f"{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}" + order_timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - order_id = f"{datetime.now().strftime('%y%m%d%H%M')}-{uuid.uuid4().hex[:4]}" new_order = { "id": order_id, - "created_at": datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + "created_at": order_timestamp, "cart": processed_cart, "total_price_usd": round(total_price_usd, 2), - "total_price_kzt": round(total_price_usd * exchange_rate_kzt), + "total_price": round(total_price_usd * exchange_rate, 2), "currency": currency, + "exchange_rate": exchange_rate, "employee": employee_name, "status": "new" } @@ -1655,7 +1880,6 @@ def create_order(): save_data(data) return jsonify({"order_id": order_id}), 201 except Exception as e: - logging.error(f"Failed to save order {order_id}: {e}", exc_info=True) return jsonify({"error": "Ошибка сервера при сохранении заказа."}), 500 @app.route('/order/') @@ -1664,179 +1888,179 @@ def view_order(order_id): order = data.get('orders', {}).get(order_id) return render_template_string(ORDER_TEMPLATE, order=order, repo_id=REPO_ID) -def upload_photos_to_hf(files, product_name): - if not HF_TOKEN_WRITE or not any(f.filename for f in files): - return [] - - photo_filenames = [] - api = HfApi() - uploads_dir = 'uploads_temp' - os.makedirs(uploads_dir, exist_ok=True) - - for photo in files: - if photo and photo.filename: - try: - safe_name = secure_filename(product_name.replace(' ', '_'))[:50] - ext = os.path.splitext(photo.filename)[1].lower() - photo_filename = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}" - temp_path = os.path.join(uploads_dir, photo_filename) - photo.save(temp_path) - api.upload_file( - path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}", - repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, - commit_message=f"Add photo for {product_name}" - ) - photo_filenames.append(photo_filename) - os.remove(temp_path) - except Exception as e: - logging.error(f"Error uploading photo {photo.filename}: {e}", exc_info=True) - return photo_filenames - @app.route('/admin', methods=['GET', 'POST']) def admin(): data = load_data() products = data.get('products', []) - + categories = data.get('categories', []) + seasons = data.get('seasons', []) + employees = data.get('employees', []) + exchange_rate_kzt = data.get('exchange_rate_kzt', 450.0) + if request.method == 'POST': action = request.form.get('action') try: if action == 'add_category': - name = request.form.get('category_name', '').strip() - if name and name not in data['categories']: - data['categories'].append(name) - flash(f"Категория '{name}' добавлена.", 'success') + category_name = request.form.get('category_name', '').strip() + if category_name and category_name not in categories: + categories.append(category_name) + data['categories'] = categories + save_data(data) + flash(f"Категория '{category_name}' успешно добавлена.", 'success') + else: + flash(f"Ошибка: Категория '{category_name}' пуста или уже существует.", 'error') + elif action == 'delete_category': - name = request.form.get('category_name') - if name in data['categories']: - data['categories'].remove(name) - for p in data['products']: - if p.get('category') == name: p['category'] = 'Без категории' - flash(f"Категория '{name}' удалена.", 'success') + category_to_delete = request.form.get('category_name') + if category_to_delete and category_to_delete in categories: + categories.remove(category_to_delete) + for product in products: + if product.get('category') == category_to_delete: + product['category'] = 'Без категории' + data['categories'] = categories + data['products'] = products + save_data(data) + flash(f"Категория '{category_to_delete}' удалена.", 'success') + elif action == 'add_season': - name = request.form.get('season_name', '').strip() - if name and name not in data['seasons']: - data['seasons'].append(name) - flash(f"Сезон '{name}' добавлен.", 'success') + season_name = request.form.get('season_name', '').strip() + if season_name and season_name not in seasons: + seasons.append(season_name) + data['seasons'] = seasons + save_data(data) + flash(f"Сезон '{season_name}' успешно добавлен.", 'success') + else: + flash(f"Ошибка: Сезон '{season_name}' пуст или уже существует.", 'error') + elif action == 'delete_season': - name = request.form.get('season_name') - if name in data['seasons']: - data['seasons'].remove(name) - for p in data['products']: - if p.get('season') == name: p['season'] = 'Без сезона' - flash(f"Сезон '{name}' удален.", 'success') + season_to_delete = request.form.get('season_name') + if season_to_delete and season_to_delete in seasons: + seasons.remove(season_to_delete) + for product in products: + if product.get('season') == season_to_delete: + product['season'] = 'Без сезона' + data['seasons'] = seasons + data['products'] = products + save_data(data) + flash(f"Сезон '{season_to_delete}' удален.", 'success') + elif action == 'add_employee': - name = request.form.get('employee_name', '').strip() - if name and name not in data['employees']: - data['employees'].append(name) - flash(f"Сотрудник '{name}' добавлен.", 'success') + employee_name = request.form.get('employee_name', '').strip() + if employee_name and employee_name not in employees: + employees.append(employee_name) + data['employees'] = employees + save_data(data) + flash(f"Сотрудник '{employee_name}' успешно добавлен.", 'success') + else: + flash(f"Ошибка: Сотрудник '{employee_name}' пуст или уже существует.", 'error') + elif action == 'delete_employee': - name = request.form.get('employee_name') - if name in data['employees']: - data['employees'].remove(name) - flash(f"Сотрудник '{name}' удален.", 'success') + employee_to_delete = request.form.get('employee_name') + if employee_to_delete and employee_to_delete in employees: + employees.remove(employee_to_delete) + data['employees'] = employees + save_data(data) + flash(f"Сотрудник '{employee_to_delete}' удален.", 'success') + elif action == 'update_rate': - rate_str = request.form.get('exchange_rate_kzt', '450.0').replace(',', '.') - data['exchange_rate_kzt'] = float(rate_str) - flash(f"Курс обновлен: {data['exchange_rate_kzt']}", 'success') + try: + new_rate = float(request.form.get('exchange_rate_kzt')) + if new_rate > 0: + data['exchange_rate_kzt'] = new_rate + save_data(data) + flash(f"Курс обновлен: {new_rate} KZT за 1 USD.", 'success') + else: + flash("Курс должен быть положительным числом.", 'error') + except (ValueError, TypeError): + flash("Неверный формат курса.", 'error') elif action == 'add_product' or action == 'edit_product': name = request.form.get('name', '').strip() - price = float(request.form.get('price', '0').replace(',', '.')) - items_per_line = int(request.form.get('items_per_line', '1')) - - variants = [] - variant_colors = request.form.getlist('variant_color') + price_str = request.form.get('price', '').replace(',', '.') + items_per_line_str = request.form.get('items_per_line', '1') + if not name or not price_str or not items_per_line_str: + flash("Название, цена и кол-во в линейке обязательны.", 'error') + return redirect(url_for('admin')) + try: + price = round(float(price_str), 2) + items_per_line = int(items_per_line_str) + except ValueError: + flash("Неверный формат цены или кол-ва в линейке.", 'error') + return redirect(url_for('admin')) - for i, color in enumerate(variant_colors): - color = color.strip() - if not color: continue - photos = request.files.getlist(f'variant_photos') - new_photos = upload_photos_to_hf(request.files.getlist(f'variant_photos_{i+1}'), name) if action == 'add_product' else [] + api = HfApi() if HF_TOKEN_WRITE else None + new_variants = [] + colors = request.form.getlist('variant_colors') + photos = request.files.getlist('variant_photos') + existing_photos = request.form.getlist('existing_photos') + photos_to_delete_str = request.form.get('photos_to_delete', '') + photos_to_delete = photos_to_delete_str.split(',') if photos_to_delete_str else [] + + for i, color in enumerate(colors): + if not color.strip(): continue - if action == 'edit_product': - photos_from_this_variant = request.files.getlist(f'variant_photos') - all_photos = [] - # This logic is simplified; a real implementation needs to map files to variant blocks + variant_photo_filename = existing_photos[i] if action == 'edit_product' and i < len(existing_photos) else None + photo_file = photos[i] if i < len(photos) else None + + if photo_file and photo_file.filename: + if variant_photo_filename and variant_photo_filename not in photos_to_delete: + photos_to_delete.append(variant_photo_filename) + + safe_name = secure_filename(name.replace(' ', '_'))[:50] + ext = os.path.splitext(photo_file.filename)[1].lower() + new_photo_name = f"{safe_name}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}" + + if api: + api.upload_file(path_or_fileobj=photo_file, path_in_repo=f"photos/{new_photo_name}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE) + variant_photo_filename = new_photo_name - variant_data = {'color': color, 'photos': new_photos} - variants.append(variant_data) + new_variants.append({'color': color.strip(), 'photo': variant_photo_filename}) - form_variants = [] - colors = request.form.getlist('variant_color') - existing_photos_list = request.form.getlist('existing_photos') + if photos_to_delete and api: + api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in photos_to_delete if p], repo_type="dataset", token=HF_TOKEN_WRITE) - for i in range(len(colors)): - color = colors[i].strip() - if not color: continue - - new_photos = upload_photos_to_hf(request.files.getlist(f'variant_photos'), name) # Simplified: would need unique names in form - - # For edit, we need a way to link uploaded files to the right variant - - # Simplified logic for now - # A robust solution needs JS to give unique names to file inputs and backend to parse them - all_photos = existing_photos_list[i].split(',') if i < len(existing_photos_list) and existing_photos_list[i] else [] - if new_photos: - # old photos should be deleted from HF here - all_photos = new_photos - - form_variants.append({'color': color, 'photos': all_photos}) - - product_data = { 'name': name, 'price': price, 'items_per_line': items_per_line, 'description': request.form.get('description', '').strip(), - 'category': request.form.get('category'), - 'season': request.form.get('season'), - 'in_stock': 'in_stock' in request.form, - 'is_top': 'is_top' in request.form, - 'variants': [] # Placeholder for complex logic + 'category': request.form.get('category'), 'season': request.form.get('season'), + 'in_stock': 'in_stock' in request.form, 'is_top': 'is_top' in request.form, + 'variants': new_variants } - + if action == 'add_product': product_data['id'] = str(uuid.uuid4()) - - # Simplified variant handling - colors = [c.strip() for c in request.form.getlist('variant_color') if c.strip()] - all_files = request.files.getlist('variant_photos') - uploaded_photos = upload_photos_to_hf(all_files, name) - if colors: - # Assuming photos are for the first color for simplicity - product_data['variants'].append({'color': colors[0], 'photos': uploaded_photos}) - for color in colors[1:]: - product_data['variants'].append({'color': color, 'photos': []}) - elif uploaded_photos: - product_data['variants'].append({'color': 'Default', 'photos': uploaded_photos}) - - data['products'].append(product_data) - flash(f"Товар '{name}' добавлен.", 'success') - - elif action == 'edit_product': + products.append(product_data) + flash(f"Товар '{name}' успешно добавлен.", 'success') + else: # edit_product product_id = request.form.get('product_id') product_index = next((i for i, p in enumerate(products) if p.get('id') == product_id), -1) - if product_index != -1: - # This part is highly complex and requires careful mapping of form fields to data structures - # The provided template structure makes this hard without client-side logic to structure form names - # Updating with simplified data for now - original_product = data['products'][product_index] + if product_index > -1: product_data['id'] = product_id - product_data['variants'] = original_product.get('variants', []) # Keep old variants as photo update is complex - data['products'][product_index] = product_data - flash(f"Товар '{name}' обновлен (фото не изменены).", 'success') + products[product_index] = product_data + flash(f"Товар '{name}' успешно обновлен.", 'success') + else: + flash(f"Ошибка: товар с ID '{product_id}' не найден.", 'error') + + data['products'] = products + save_data(data) elif action == 'delete_product': product_id = request.form.get('product_id') product_to_delete = next((p for p in products if p.get('id') == product_id), None) if product_to_delete: - data['products'].remove(product_to_delete) - # Deleting photos from HF should happen here - flash(f"Товар '{product_to_delete['name']}' удален.", 'success') + photos_to_delete = [v.get('photo') for v in product_to_delete.get('variants', []) if v.get('photo')] + if photos_to_delete and HF_TOKEN_WRITE: + api = HfApi() + api.delete_files(repo_id=REPO_ID, paths_in_repo=[f"photos/{p}" for p in photos_to_delete], repo_type="dataset", token=HF_TOKEN_WRITE) + + products.remove(product_to_delete) + data['products'] = products + save_data(data) + flash(f"Товар '{product_to_delete.get('name')}' удален.", 'success') - save_data(data) return redirect(url_for('admin')) except Exception as e: - logging.error(f"Error processing admin action '{action}': {e}", exc_info=True) - flash(f"Произошла ошибка: {e}", 'error') + flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'.", 'error') return redirect(url_for('admin')) current_data = load_data() @@ -1846,30 +2070,40 @@ def admin(): categories=sorted(current_data.get('categories', [])), seasons=sorted(current_data.get('seasons', [])), employees=sorted(current_data.get('employees', [])), - repo_id=REPO_ID, - exchange_rate_kzt=current_data.get('exchange_rate_kzt', 450.0) + exchange_rate_kzt=current_data.get('exchange_rate_kzt', 450.0), + repo_id=REPO_ID ) @app.route('/force_upload', methods=['POST']) def force_upload(): - upload_db_to_hf() - flash("Данные загружены на Hugging Face.", 'success') + try: + upload_db_to_hf() + flash("Данные успешно загружены на Hugging Face.", 'success') + except Exception as e: + flash(f"Ошибка при загрузке на Hugging Face: {e}", 'error') return redirect(url_for('admin')) @app.route('/force_download', methods=['POST']) def force_download(): - if download_db_from_hf(): - flash("Данные скачаны с Hugging Face.", 'success') - else: - flash("Не удалось скачать данные.", 'error') + try: + if download_db_from_hf(): + flash("Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.", 'success') + load_data() + else: + flash("Не удалось скачать данные с Hugging Face после нескольких попыток.", 'error') + except Exception as e: + flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error') return redirect(url_for('admin')) + + if __name__ == '__main__': - logging.info("Application starting up...") download_db_from_hf() load_data() + if HF_TOKEN_WRITE: backup_thread = threading.Thread(target=periodic_backup, daemon=True) backup_thread.start() + port = int(os.environ.get('PORT', 7860)) app.run(debug=False, host='0.0.0.0', port=port)