From 9dab280def82a04be5e2e670f17d6cb4f3bb7a46 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Wed, 3 Dec 2025 01:08:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F:=20?= =?UTF-8?q?=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20UI,=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B8?= =?UTF-8?q?=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Собрал накопившиеся изменения из рабочей директории: UI улучшения: - customer_detail.html: Расширен интерфейс детальной страницы клиента - order_detail.html: Добавлены элементы отображения деталей заказа - order_list.html: Улучшена визуализация списка заказов Бэкенд: - customers/views.py: Доработаны представления для работы с клиентами - products/views/product_views.py: Минорные правки - user_roles/auth_backend.py: Добавлен кастомный бэкенд авторизации Настройки: - myproject/settings.py: Обновлены конфигурации - .gitignore: Добавлен для игнорирования служебных файлов - requirements.txt: Удален (вероятно заменен на poetry/pipenv) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- myproject/.gitignore | 79 ++++++++++ .../templates/customers/customer_detail.html | 58 ++++++- myproject/customers/views.py | 32 +++- myproject/myproject/settings.py | 12 ++ .../orders/templates/orders/order_detail.html | 16 ++ .../orders/templates/orders/order_list.html | 4 + myproject/products/views/product_views.py | 1 + myproject/requirements.txt | 34 ----- myproject/user_roles/auth_backend.py | 142 ++++++++++++++++++ 9 files changed, 332 insertions(+), 46 deletions(-) create mode 100644 myproject/.gitignore delete mode 100644 myproject/requirements.txt create mode 100644 myproject/user_roles/auth_backend.py diff --git a/myproject/.gitignore b/myproject/.gitignore new file mode 100644 index 0000000..2e6bf1e --- /dev/null +++ b/myproject/.gitignore @@ -0,0 +1,79 @@ +# Django +*.log +*.pot +*.pyc +__pycache__/ +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Environment variables (contains secrets!) +.env + +# Virtual environment +venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Static and media files +/staticfiles/ +/media/ + +# Python +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Migrations (раскомментируйте если не хотите коммитить миграции) +# */migrations/*.py +# !*/migrations/__init__.py + +# Celery Beat schedule database (автоматически создаётся при запуске celery beat) +celerybeat-schedule +celerybeat-schedule-shm +celerybeat-schedule-wal + +# Documentation files in root (сгенерированные документы) +/CELERY_SETUP_GUIDE.md +/FINAL_REPORT.md +/IMPLEMENTATION_SUMMARY.md +/MIGRATION_GUIDE.md +/QUICK_START.md +/README_CELERY.md +/start_celery.bat +/start_celery.sh diff --git a/myproject/customers/templates/customers/customer_detail.html b/myproject/customers/templates/customers/customer_detail.html index 8f4f1c0..c80b92f 100644 --- a/myproject/customers/templates/customers/customer_detail.html +++ b/myproject/customers/templates/customers/customer_detail.html @@ -70,6 +70,28 @@ + + {% if refund_amount > 0 %} +
+ +
+ {% endif %} +
@@ -268,12 +290,13 @@ Сумма Оплачено Остаток + Возврат Действия {% for order in orders_page %} - + 0 %}class="table-warning"{% endif %}> #{{ order.order_number }} {{ order.created_at|date:"d.m.Y H:i" }} @@ -313,12 +336,22 @@ {% endif %} - {% if order.payment_status == 'paid' %} - Оплачено - {% elif order.payment_status == 'partial' %} - Частично + {% if order.is_paid %} + + Оплачено + + {% elif order.status and order.status.is_negative_end and order.amount_paid > 0 %} + + Возврат + + {% elif order.amount_paid > 0 %} + + Частично ({{ order.amount_paid|floatformat:2 }} руб.) + {% else %} - Не оплачено + + Не оплачено + {% endif %} {{ order.total_amount|floatformat:2 }} руб. @@ -330,12 +363,23 @@ {% endif %} - {% if order.amount_due > 0 %} + {% if order.status and order.status.is_negative_end %} + + {% elif order.amount_due > 0 %} {{ order.amount_due|floatformat:2 }} руб. {% else %} 0.00 руб. {% endif %} + + {% if order.status and order.status.is_negative_end and order.amount_paid > 0 %} + + {{ order.amount_paid|floatformat:2 }} руб. + + {% else %} + + {% endif %} + diff --git a/myproject/customers/views.py b/myproject/customers/views.py index 368e0ba..118df7a 100644 --- a/myproject/customers/views.py +++ b/myproject/customers/views.py @@ -87,8 +87,13 @@ def customer_detail(request, pk): if customer.is_system_customer: return render(request, 'customers/customer_system.html') - # Рассчитываем общий долг по активным заказам на стороне БД - total_debt_result = customer.orders.exclude(payment_status='paid').aggregate( + # Рассчитываем общий долг по заказам на стороне БД + # Долг = все заказы КРОМЕ отмененных и полностью оплаченных + # ВКЛЮЧАЕТ завершенные заказы с неполной оплатой! + total_debt_result = customer.orders.exclude( + Q(status__is_negative_end=True) | # Отмененные → учитываются в refund_amount + Q(payment_status='paid') # Полностью оплаченные + ).aggregate( total_debt=Coalesce( Sum(Greatest(F('total_amount') - F('amount_paid'), Value(0), output_field=DecimalField())), Value(0), @@ -96,9 +101,25 @@ def customer_detail(request, pk): ) ) total_debt = total_debt_result['total_debt'] or Decimal('0') - - # Количество активных заказов - active_orders_count = customer.orders.exclude(payment_status='paid').count() + + # Количество заказов с долгом (с той же логикой) + active_orders_count = customer.orders.exclude( + Q(status__is_negative_end=True) | + Q(payment_status='paid') + ).count() + + # Сумма к возврату (отмененные заказы с оплатой) + refund_amount_result = customer.orders.filter( + status__is_negative_end=True, # Отмененные + amount_paid__gt=0 # С оплатой + ).aggregate( + total_refund=Coalesce( + Sum('amount_paid'), + Value(0), + output_field=DecimalField() + ) + ) + refund_amount = refund_amount_result['total_refund'] or Decimal('0') # История транзакций кошелька (последние 20) from .models import WalletTransaction @@ -116,6 +137,7 @@ def customer_detail(request, pk): 'customer': customer, 'total_debt': total_debt, 'active_orders_count': active_orders_count, + 'refund_amount': refund_amount, 'wallet_transactions': wallet_transactions, 'orders_page': orders_page, } diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index 1d97197..8c027c5 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -107,6 +107,18 @@ MIDDLEWARE = [ ] +# ============================================ +# AUTHENTICATION BACKENDS +# ============================================ + +# Кастомный backend для связи ролей с Django permissions API +# ВАЖНО: Этот backend работает с ролями из tenant schema, НЕ трогая public schema! +AUTHENTICATION_BACKENDS = [ + 'user_roles.auth_backend.RoleBasedPermissionBackend', # Наш кастомный backend для ролей + 'django.contrib.auth.backends.ModelBackend', # Стандартный backend (для superuser и т.д.) +] + + # ============================================ # URL CONFIGURATION # ============================================ diff --git a/myproject/orders/templates/orders/order_detail.html b/myproject/orders/templates/orders/order_detail.html index c5c6012..f197b35 100644 --- a/myproject/orders/templates/orders/order_detail.html +++ b/myproject/orders/templates/orders/order_detail.html @@ -329,6 +329,22 @@ {% endif %}
+ + + {% if order.status and order.status.is_negative_end and order.amount_paid > 0 %} +
+
+
+ Требуется возврат +
+

+ Заказ отменён, но клиент внёс оплату: {{ order.amount_paid|floatformat:2 }} руб. +

+ + Создайте возврат через раздел "История транзакций" ниже + +
+ {% endif %} diff --git a/myproject/orders/templates/orders/order_list.html b/myproject/orders/templates/orders/order_list.html index 8839427..3fc71f7 100644 --- a/myproject/orders/templates/orders/order_list.html +++ b/myproject/orders/templates/orders/order_list.html @@ -153,6 +153,10 @@ Оплачен + {% elif order.status and order.status.is_negative_end and order.amount_paid > 0 %} + + Возврат + {% elif order.amount_paid > 0 %} Частично ({{ order.amount_paid }} руб.) diff --git a/myproject/products/views/product_views.py b/myproject/products/views/product_views.py index 3d065ef..4ae9205 100644 --- a/myproject/products/views/product_views.py +++ b/myproject/products/views/product_views.py @@ -366,6 +366,7 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis context['item_statuses'] = item_statuses # Кнопки действий + # Проверяем права через has_perm, который использует наш RoleBasedPermissionBackend action_buttons = [] if self.request.user.has_perm('products.add_product'): diff --git a/myproject/requirements.txt b/myproject/requirements.txt deleted file mode 100644 index 19e2081..0000000 --- a/myproject/requirements.txt +++ /dev/null @@ -1,34 +0,0 @@ -amqp==5.3.1 -asgiref==3.9.0 -billiard==4.2.2 -celery==5.4.0 -click==8.3.0 -click-didyoumean==0.3.1 -click-plugins==1.1.1.2 -click-repl==0.3.0 -colorama==0.4.6 -Django==5.0.10 -django-celery-results==2.5.1 -django-environ==0.12.0 -django-filter==24.3 -django-nested-admin==4.1.5 -django-phonenumber-field==8.3.0 -django-simple-history==3.10.1 -django-tenants==3.7.0 -kombu==5.6.0 -packaging==25.0 -phonenumbers==9.0.17 -pillow>=12.0.0 -pillow-heif>=0.15.0 -prompt_toolkit==3.0.52 -psycopg2-binary==2.9.11 -python-dateutil==2.9.0.post0 -python-monkey-business==1.1.0 -redis==5.0.8 -six==1.17.0 -sqlparse==0.5.3 -typing_extensions==4.15.0 -tzdata==2025.2 -Unidecode==1.4.0 -vine==5.1.0 -wcwidth==0.2.14 diff --git a/myproject/user_roles/auth_backend.py b/myproject/user_roles/auth_backend.py new file mode 100644 index 0000000..5a481c8 --- /dev/null +++ b/myproject/user_roles/auth_backend.py @@ -0,0 +1,142 @@ +""" +Кастомный backend аутентификации для связывания ролей с Django permissions API. + +ВАЖНО: Этот backend НЕ использует таблицы Django permissions из public schema! +Он только эмулирует API has_perm(), читая роли из текущей tenant schema. +Это безопасно для мультитенантной архитектуры. +""" +from django.contrib.auth.backends import ModelBackend +from user_roles.services import RoleService +from user_roles.models import Role + + +class RoleBasedPermissionBackend(ModelBackend): + """ + Backend, который предоставляет права на основе роли пользователя в текущем тенанте. + + Расширяет стандартный ModelBackend, добавляя проверку прав на основе ролей из tenant schema. + + Как это работает: + 1. Django вызывает user.has_perm('products.add_product') + 2. Backend проверяет роль пользователя в ТЕКУЩЕЙ tenant schema (через RoleService) + 3. На основе роли возвращает True/False + 4. Никакие данные из public schema не используются! + """ + + # Маппинг ролей на наборы разрешений + # Формат: 'app_label': ['action1', 'action2', ...] + # где action - это префикс permission: add_product -> add, change_order -> change + ROLE_PERMISSIONS = { + Role.OWNER: { + # Владелец: полный доступ ко всем модулям + 'products': ['add', 'change', 'delete', 'view'], + 'inventory': ['add', 'change', 'delete', 'view'], + 'orders': ['add', 'change', 'delete', 'view'], + 'clients': ['add', 'change', 'delete', 'view'], + 'suppliers': ['add', 'change', 'delete', 'view'], + 'user_roles': ['add', 'change', 'delete', 'view'], + 'payments': ['add', 'change', 'delete', 'view'], + }, + Role.MANAGER: { + # Менеджер: доступ к основным операционным модулям (без настроек) + 'products': ['add', 'change', 'delete', 'view'], + 'inventory': ['add', 'change', 'delete', 'view'], + 'orders': ['add', 'change', 'delete', 'view'], + 'clients': ['add', 'change', 'delete', 'view'], + 'suppliers': ['add', 'change', 'view'], # только просмотр и изменение, без удаления + 'payments': ['view'], + }, + Role.FLORIST: { + # Флорист: работа с заказами и просмотр товаров + 'products': ['view'], + 'inventory': ['view', 'change'], # может обновлять остатки при сборке заказов + 'orders': ['change', 'view'], + }, + Role.COURIER: { + # Курьер: только просмотр и обновление статуса заказов + 'orders': ['change', 'view'], + }, + } + + def has_perm(self, user_obj, perm, obj=None): + """ + Проверяет, имеет ли пользователь указанное разрешение. + + Формат разрешения: 'app_label.action_modelname' + Например: 'products.add_product', 'orders.change_order' + + Args: + user_obj: Пользователь + perm: Строка разрешения в формате 'app.action_model' + obj: Опциональный объект для object-level permissions + + Returns: + bool: True если пользователь имеет разрешение + """ + # Сначала проверяем стандартные permissions через ModelBackend + # (для superuser, staff с Django permissions и т.д.) + if super().has_perm(user_obj, perm, obj): + return True + + # Если пользователь не аутентифицирован, нет доступа + if not user_obj.is_authenticated: + return False + + # Суперпользователь имеет все права + if user_obj.is_superuser: + return True + + # Получаем роль пользователя в текущем тенанте + # ВАЖНО: RoleService работает с текущей tenant schema! + user_role = RoleService.get_user_role(user_obj) + if not user_role: + return False + + # Парсим разрешение: 'app_label.action_modelname' + # Например: 'products.add_product' -> app_label='products', action='add' + try: + app_label, codename = perm.split('.') + # Извлекаем действие из codename (add_product -> add, change_order -> change) + action = codename.split('_')[0] + except (ValueError, IndexError): + return False + + # Проверяем, есть ли у роли это разрешение + role_perms = self.ROLE_PERMISSIONS.get(user_role.code, {}) + app_perms = role_perms.get(app_label, []) + + return action in app_perms + + def has_module_perms(self, user_obj, app_label): + """ + Проверяет, имеет ли пользователь какие-либо разрешения для приложения. + + Используется в Django admin для определения, показывать ли модуль в навигации. + + Args: + user_obj: Пользователь + app_label: Название приложения (например, 'products', 'orders') + + Returns: + bool: True если пользователь имеет хотя бы одно разрешение для приложения + """ + # Сначала проверяем стандартные permissions через ModelBackend + if super().has_module_perms(user_obj, app_label): + return True + + # Если пользователь не аутентифицирован, нет доступа + if not user_obj.is_authenticated: + return False + + # Суперпользователь имеет все права + if user_obj.is_superuser: + return True + + # Получаем роль пользователя в текущем тенанте + user_role = RoleService.get_user_role(user_obj) + if not user_role: + return False + + # Проверяем, есть ли у роли какие-либо разрешения для этого приложения + role_perms = self.ROLE_PERMISSIONS.get(user_role.code, {}) + return app_label in role_perms and len(role_perms[app_label]) > 0