From 1edeaeb552434aad38d0b062f3c749aa5b006f3d Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sat, 8 Nov 2025 22:38:10 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5=D1=80=D0=B6=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=87=D0=B5=D1=80=D0=BD=D0=BE=D0=B2=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2=20(=D0=AD?= =?UTF-8?q?=D1=82=D0=B0=D0=BF=203/3):=20JavaScript,=20UI=20=D0=B8=20cleanu?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JavaScript: - Создан autosave.js: модуль автосохранения черновиков - Debouncing с задержкой 3 секунды - Визуальный индикатор статуса сохранения - Автоматическое отслеживание изменений в полях формы и formset UI обновления: - order_form.html: добавлен data-атрибут is-draft и подключение autosave.js - order_list.html: добавлен badge "Черновик" для черновиков в списке заказов Management команда: - cleanup_draft_orders: очистка старых черновиков (по умолчанию 30 дней) - Поддержка --dry-run для предпросмотра - Автоматическое удаление связанных временных комплектов Использование: - python manage.py cleanup_draft_orders --days=30 - python manage.py cleanup_draft_orders --days=7 --dry-run Система черновиков полностью готова к использованию! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../commands/cleanup_draft_orders.py | 95 ++++ myproject/orders/static/orders/js/autosave.js | 504 ++++++++++++++++++ .../orders/templates/orders/order_form.html | 7 +- .../orders/templates/orders/order_list.html | 6 +- 4 files changed, 610 insertions(+), 2 deletions(-) create mode 100644 myproject/orders/management/commands/cleanup_draft_orders.py create mode 100644 myproject/orders/static/orders/js/autosave.js diff --git a/myproject/orders/management/commands/cleanup_draft_orders.py b/myproject/orders/management/commands/cleanup_draft_orders.py new file mode 100644 index 0000000..64be32b --- /dev/null +++ b/myproject/orders/management/commands/cleanup_draft_orders.py @@ -0,0 +1,95 @@ +""" +Management :><0=40 4;O >G8AB:8 AB0@KE G5@=>28:>2 70:07>2. + +#40;O5B G5@=>28:8 70:07>2, :>B>@K5 =5 >1=>2;O;8AL 1>;55 C:070==>3> :>;8G5AB20 4=59. +"0:65 C40;O5B A2O70==K5 2@5<5==K5 :>;L7>20=85: + python manage.py cleanup_draft_orders --days=30 + python manage.py cleanup_draft_orders --days=7 --dry-run +""" + +from django.core.management.base import BaseCommand, CommandError +from orders.services import DraftOrderService + + +class Command(BaseCommand): + help = '#40;O5B AB0@K5 G5@=>28:8 70:07>2 8 A2O70==K5 2@5<5==K5 :>;8G5AB2> 4=59, ?>A;5 :>B>@KE G5@=>28: AG8B05BAO AB0@K< (?> C<>;G0=8N: 30)' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='>:070BL :>;8G5AB2> G5@=>28:>2 4;O C40;5=8O 157 D0:B8G5A:>3> C40;5=8O' + ) + + def handle(self, *args, **options): + days = options['days'] + dry_run = options['dry_run'] + + if days < 1: + raise CommandError('>;8G5AB2> 4=59 4>;6=> 1KBL 1>;LH5 0') + + self.stdout.write( + self.style.WARNING( + f'>8A: G5@=>28:>2 AB0@H5 {days} 4=59...' + ) + ) + + if dry_run: + #  @568<5 dry-run B>;L:> ?>4AG8BK205< + from datetime import timedelta + from django.utils import timezone + from orders.models import Order + + cutoff_date = timezone.now() - timedelta(days=days) + old_drafts = Order.objects.filter( + status='draft', + last_autosave_at__lt=cutoff_date + ) + count = old_drafts.count() + + if count == 0: + self.stdout.write( + self.style.SUCCESS( + f'5B G5@=>28:>2 AB0@H5 {days} 4=59' + ) + ) + else: + self.stdout.write( + self.style.WARNING( + f'0945=> {count} G5@=>28:>2 4;O C40;5=8O:' + ) + ) + for draft in old_drafts: + last_save = draft.last_autosave_at.strftime('%d.%m.%Y %H:%M') if draft.last_autosave_at else '=8:>340' + self.stdout.write( + f' - 0:07 #{draft.order_number} (?>A;54=55 A>E@0=5=85: {last_save})' + ) + self.stdout.write( + self.style.WARNING( + f'\n0?CAB8B5 157 --dry-run 4;O D0:B8G5A:>3> C40;5=8O' + ) + ) + else: + # $0:B8G5A:>5 C40;5=85 + count = DraftOrderService.delete_old_drafts(days=days) + + if count == 0: + self.stdout.write( + self.style.SUCCESS( + f'5B G5@=>28:>2 AB0@H5 {days} 4=59' + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + f'#A?5H=> C40;5=> {count} G5@=>28:>2 8 A2O70==KE 2@5<5==KE :>2' + ) + ) diff --git a/myproject/orders/static/orders/js/autosave.js b/myproject/orders/static/orders/js/autosave.js new file mode 100644 index 0000000..e2b7e54 --- /dev/null +++ b/myproject/orders/static/orders/js/autosave.js @@ -0,0 +1,504 @@ +/** + * Модуль автосохранения черновиков заказов. + * + * Автоматически сохраняет изменения в черновике заказа при изменении полей формы. + * Использует debouncing для уменьшения количества запросов к серверу. + */ + +(function() { + 'use strict'; + + // Конфигурация + const CONFIG = { + AUTOSAVE_DELAY: 3000, // Задержка перед автосохранением (мс) + AUTOSAVE_URL_PATTERN: '/orders/{orderId}/autosave/', + STATUS_DISPLAY_DURATION: 5000, // Длительность показа статуса (мс) + }; + + // Состояние модуля + let autosaveTimer = null; + let isAutosaving = false; + let isDraft = false; + let orderId = null; + + /** + * Инициализация модуля автосохранения + */ + function init() { + // Проверяем, что мы на странице редактирования черновика + const orderForm = document.querySelector('form[action*="edit"]'); + if (!orderForm) { + return; + } + + // Получаем ID заказа из URL + const urlMatch = window.location.pathname.match(/\/orders\/(\d+)\/edit\//); + if (!urlMatch) { + return; + } + orderId = urlMatch[1]; + + // Проверяем, является ли заказ черновиком + isDraft = checkIfDraft(); + if (!isDraft) { + return; + } + + console.log('[Autosave] Initialized for draft order #' + orderId); + + // Инициализируем UI индикатора + initStatusIndicator(); + + // Добавляем обработчики событий + attachEventListeners(); + } + + /** + * Проверяет, является ли заказ черновиком + */ + function checkIfDraft() { + // Проверяем через data-атрибут на форме + const form = document.querySelector('form[action*="edit"]'); + if (form && form.dataset.isDraft === 'true') { + return true; + } + + // Проверяем через заголовок страницы + const title = document.querySelector('h1'); + if (title && title.textContent.includes('черновик')) { + return true; + } + + return false; + } + + /** + * Создает индикатор статуса автосохранения + */ + function initStatusIndicator() { + // Проверяем, не создан ли уже индикатор + if (document.getElementById('autosave-status')) { + return; + } + + const indicator = document.createElement('div'); + indicator.id = 'autosave-status'; + indicator.className = 'alert alert-info'; + indicator.style.cssText = ` + position: fixed; + top: 70px; + right: 20px; + z-index: 1050; + min-width: 250px; + display: none; + padding: 10px 15px; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + `; + indicator.innerHTML = ` +
+ + Автосохранение... +
+ `; + + document.body.appendChild(indicator); + } + + /** + * Показывает статус автосохранения + */ + function showStatus(type, message) { + const indicator = document.getElementById('autosave-status'); + const icon = document.getElementById('autosave-icon'); + const text = document.getElementById('autosave-text'); + + if (!indicator || !icon || !text) { + return; + } + + // Убираем все классы + indicator.className = 'alert'; + + // Устанавливаем соответствующий класс и иконку + switch (type) { + case 'saving': + indicator.classList.add('alert-info'); + icon.innerHTML = ''; + break; + case 'success': + indicator.classList.add('alert-success'); + icon.innerHTML = ''; + break; + case 'error': + indicator.classList.add('alert-danger'); + icon.innerHTML = ''; + break; + } + + text.textContent = message; + indicator.style.display = 'block'; + + // Автоматически скрываем статус (кроме ошибок) + if (type !== 'error') { + setTimeout(() => { + indicator.style.display = 'none'; + }, CONFIG.STATUS_DISPLAY_DURATION); + } + } + + /** + * Прикрепляет обработчики событий к полям формы + */ + function attachEventListeners() { + const form = document.querySelector('form[action*="edit"]'); + if (!form) { + return; + } + + // Слушаем изменения в основных полях заказа + const fieldsToWatch = [ + 'select[name="customer"]', + 'input[name="delivery_date"]', + 'input[name="delivery_time_start"]', + 'input[name="delivery_time_end"]', + 'input[name="delivery_cost"]', + 'select[name="payment_method"]', + 'textarea[name="special_instructions"]', + 'input[name="discount_amount"]', + 'input[type="checkbox"]', + 'input[type="radio"]', + 'select[name="delivery_address"]', + 'select[name="pickup_shop"]', + ]; + + fieldsToWatch.forEach(selector => { + const fields = form.querySelectorAll(selector); + fields.forEach(field => { + // Для select и checkbox используем 'change' + if (field.tagName === 'SELECT' || field.type === 'checkbox' || field.type === 'radio') { + field.addEventListener('change', scheduleAutosave); + } else { + // Для текстовых полей используем 'input' + field.addEventListener('input', scheduleAutosave); + } + }); + }); + + // Слушаем изменения в формах товаров (formset) + observeFormsetChanges(); + + console.log('[Autosave] Event listeners attached'); + } + + /** + * Наблюдает за изменениями в формсете товаров + */ + function observeFormsetChanges() { + const formsetContainer = document.getElementById('order-items-formset'); + if (!formsetContainer) { + return; + } + + // Наблюдаем за добавлением/удалением форм + const observer = new MutationObserver(() => { + attachFormsetEventListeners(); + }); + + observer.observe(formsetContainer, { + childList: true, + subtree: true + }); + + // Прикрепляем обработчики к существующим формам + attachFormsetEventListeners(); + } + + /** + * Прикрепляет обработчики к полям в формах товаров + */ + function attachFormsetEventListeners() { + const itemForms = document.querySelectorAll('.order-item-form'); + + itemForms.forEach(form => { + // Если уже прикреплены обработчики, пропускаем + if (form.dataset.autosaveAttached === 'true') { + return; + } + + const fields = form.querySelectorAll('select, input[type="number"], input[type="checkbox"]'); + fields.forEach(field => { + if (field.tagName === 'SELECT' || field.type === 'checkbox') { + field.addEventListener('change', scheduleAutosave); + } else { + field.addEventListener('input', scheduleAutosave); + } + }); + + form.dataset.autosaveAttached = 'true'; + }); + } + + /** + * Планирует автосохранение с задержкой (debouncing) + */ + function scheduleAutosave() { + // Отменяем предыдущий таймер + if (autosaveTimer) { + clearTimeout(autosaveTimer); + } + + // Устанавливаем новый таймер + autosaveTimer = setTimeout(() => { + performAutosave(); + }, CONFIG.AUTOSAVE_DELAY); + + console.log('[Autosave] Scheduled in ' + CONFIG.AUTOSAVE_DELAY + 'ms'); + } + + /** + * Выполняет автосохранение + */ + async function performAutosave() { + if (isAutosaving) { + console.log('[Autosave] Already in progress, skipping'); + return; + } + + isAutosaving = true; + showStatus('saving', 'Сохранение...'); + + try { + // Собираем данные формы + const formData = collectFormData(); + + // Отправляем AJAX запрос + const url = CONFIG.AUTOSAVE_URL_PATTERN.replace('{orderId}', orderId); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCsrfToken(), + }, + body: JSON.stringify(formData) + }); + + const data = await response.json(); + + if (response.ok && data.success) { + const lastSaved = formatDateTime(data.last_saved); + showStatus('success', 'Сохранено ' + lastSaved); + console.log('[Autosave] Success:', data); + } else { + showStatus('error', 'Ошибка: ' + (data.error || 'Неизвестная ошибка')); + console.error('[Autosave] Error:', data); + } + + } catch (error) { + showStatus('error', 'Ошибка соединения с сервером'); + console.error('[Autosave] Exception:', error); + } finally { + isAutosaving = false; + } + } + + /** + * Собирает данные формы для отправки + */ + function collectFormData() { + const form = document.querySelector('form[action*="edit"]'); + const data = {}; + + // Основные поля заказа + const customerField = form.querySelector('select[name="customer"]'); + if (customerField && customerField.value) { + data.customer = parseInt(customerField.value); + } + + const deliveryDateField = form.querySelector('input[name="delivery_date"]'); + if (deliveryDateField && deliveryDateField.value) { + data.delivery_date = deliveryDateField.value; + } + + const deliveryTimeStartField = form.querySelector('input[name="delivery_time_start"]'); + if (deliveryTimeStartField && deliveryTimeStartField.value) { + data.delivery_time_start = deliveryTimeStartField.value; + } + + const deliveryTimeEndField = form.querySelector('input[name="delivery_time_end"]'); + if (deliveryTimeEndField && deliveryTimeEndField.value) { + data.delivery_time_end = deliveryTimeEndField.value; + } + + const deliveryCostField = form.querySelector('input[name="delivery_cost"]'); + if (deliveryCostField && deliveryCostField.value) { + data.delivery_cost = deliveryCostField.value; + } + + const paymentMethodField = form.querySelector('select[name="payment_method"]'); + if (paymentMethodField && paymentMethodField.value) { + data.payment_method = paymentMethodField.value; + } + + const specialInstructionsField = form.querySelector('textarea[name="special_instructions"]'); + if (specialInstructionsField) { + data.special_instructions = specialInstructionsField.value; + } + + const discountAmountField = form.querySelector('input[name="discount_amount"]'); + if (discountAmountField && discountAmountField.value) { + data.discount_amount = discountAmountField.value; + } + + // Checkbox поля + const isDeliveryField = form.querySelector('input[name="is_delivery"]'); + if (isDeliveryField) { + data.is_delivery = isDeliveryField.checked; + } + + const customerIsRecipientField = form.querySelector('input[name="customer_is_recipient"]'); + if (customerIsRecipientField) { + data.customer_is_recipient = customerIsRecipientField.checked; + } + + const isAnonymousField = form.querySelector('input[name="is_anonymous"]'); + if (isAnonymousField) { + data.is_anonymous = isAnonymousField.checked; + } + + // Адрес доставки или точка самовывоза + const deliveryAddressField = form.querySelector('select[name="delivery_address"]'); + if (deliveryAddressField && deliveryAddressField.value) { + data.delivery_address = parseInt(deliveryAddressField.value); + } + + const pickupShopField = form.querySelector('select[name="pickup_shop"]'); + if (pickupShopField && pickupShopField.value) { + data.pickup_shop = parseInt(pickupShopField.value); + } + + // Собираем позиции заказа + data.items = collectOrderItems(); + + // Флаг для пересчета итоговой суммы + data.recalculate = true; + + return data; + } + + /** + * Собирает данные о позициях заказа + */ + function collectOrderItems() { + const items = []; + const itemForms = document.querySelectorAll('.order-item-form'); + + itemForms.forEach((form, index) => { + // Пропускаем удаленные формы + const deleteCheckbox = form.querySelector('input[name$="-DELETE"]'); + if (deleteCheckbox && deleteCheckbox.checked) { + return; + } + + // Получаем выбранный товар/комплект + const itemSelect = form.querySelector('.select2-order-item'); + if (!itemSelect || !itemSelect.value) { + return; + } + + const itemValue = itemSelect.value; + const quantityInput = form.querySelector('input[name$="-quantity"]'); + const priceInput = form.querySelector('input[name$="-price"]'); + + if (!quantityInput || !priceInput) { + return; + } + + const item = { + quantity: quantityInput.value || '1', + price: priceInput.value || '0' + }; + + // Определяем тип: товар или комплект + if (itemValue.startsWith('product_')) { + item.product_id = parseInt(itemValue.replace('product_', '')); + } else if (itemValue.startsWith('kit_')) { + item.product_kit_id = parseInt(itemValue.replace('kit_', '')); + } + + items.push(item); + }); + + return items; + } + + /** + * Получает CSRF токен из cookies или meta тега + */ + function getCsrfToken() { + // Пробуем получить из cookie + const name = 'csrftoken'; + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + + // Если не нашли в cookie, пробуем в meta теге + if (!cookieValue) { + const metaTag = document.querySelector('meta[name="csrf-token"]'); + if (metaTag) { + cookieValue = metaTag.getAttribute('content'); + } + } + + // Если не нашли в meta теге, пробуем в input поле + if (!cookieValue) { + const csrfInput = document.querySelector('input[name="csrfmiddlewaretoken"]'); + if (csrfInput) { + cookieValue = csrfInput.value; + } + } + + return cookieValue; + } + + /** + * Форматирует дату и время для отображения + */ + function formatDateTime(isoString) { + if (!isoString) { + return 'только что'; + } + + const date = new Date(isoString); + const now = new Date(); + const diffMs = now - date; + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + + if (diffSecs < 60) { + return 'только что'; + } else if (diffMins < 60) { + return diffMins + ' мин. назад'; + } else { + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return 'в ' + hours + ':' + minutes; + } + } + + // Инициализация при загрузке DOM + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + +})(); diff --git a/myproject/orders/templates/orders/order_form.html b/myproject/orders/templates/orders/order_form.html index 0983b3c..a6bd473 100644 --- a/myproject/orders/templates/orders/order_form.html +++ b/myproject/orders/templates/orders/order_form.html @@ -16,7 +16,7 @@ -
+ {% csrf_token %} @@ -791,4 +791,9 @@ document.addEventListener('DOMContentLoaded', function() { + + +{% if is_draft %} + +{% endif %} {% endblock %} diff --git a/myproject/orders/templates/orders/order_list.html b/myproject/orders/templates/orders/order_list.html index 2eac286..7b7708e 100644 --- a/myproject/orders/templates/orders/order_list.html +++ b/myproject/orders/templates/orders/order_list.html @@ -130,7 +130,11 @@ {% endif %} - {% if order.status == 'new' %} + {% if order.status == 'draft' %} + + Черновик + + {% elif order.status == 'new' %} Новый {% elif order.status == 'confirmed' %} Подтвержден