Рабочие изменения: улучшения UI, настройки и бэкенд авторизации

Собрал накопившиеся изменения из рабочей директории:

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 <noreply@anthropic.com>
This commit is contained in:
2025-12-03 01:08:53 +03:00
parent dcfb76121d
commit 9dab280def
9 changed files with 332 additions and 46 deletions

79
myproject/.gitignore vendored Normal file
View File

@@ -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

View File

@@ -70,6 +70,28 @@
</div> </div>
</div> </div>
<!-- Алерт о необходимости возврата -->
{% if refund_amount > 0 %}
<div class="col-md-12">
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-4" role="alert">
<div>
<h5 class="alert-heading mb-2">
<i class="bi bi-exclamation-triangle-fill"></i> Требуется возврат средств
</h5>
<p class="mb-0">
Клиент имеет отменённые заказы с внесённой оплатой.
Общая сумма к возврату: <strong>{{ refund_amount|floatformat:2 }} руб.</strong>
</p>
</div>
<div>
<span class="badge bg-warning text-dark" style="font-size: 1.2em;">
{{ refund_amount|floatformat:2 }} руб.
</span>
</div>
</div>
</div>
{% endif %}
<!-- Операции с кошельком --> <!-- Операции с кошельком -->
<div class="col-md-6"> <div class="col-md-6">
<div class="card mb-4"> <div class="card mb-4">
@@ -268,12 +290,13 @@
<th>Сумма</th> <th>Сумма</th>
<th>Оплачено</th> <th>Оплачено</th>
<th>Остаток</th> <th>Остаток</th>
<th>Возврат</th>
<th>Действия</th> <th>Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for order in orders_page %} {% for order in orders_page %}
<tr> <tr {% if order.status and order.status.is_negative_end and order.amount_paid > 0 %}class="table-warning"{% endif %}>
<td><strong>#{{ order.order_number }}</strong></td> <td><strong>#{{ order.order_number }}</strong></td>
<td><small>{{ order.created_at|date:"d.m.Y H:i" }}</small></td> <td><small>{{ order.created_at|date:"d.m.Y H:i" }}</small></td>
<td> <td>
@@ -313,12 +336,22 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if order.payment_status == 'paid' %} {% if order.is_paid %}
<span class="badge bg-success">Оплачено</span> <span class="badge bg-success">
{% elif order.payment_status == 'partial' %} <i class="bi bi-check-circle"></i> Оплачено
<span class="badge bg-warning">Частично</span> </span>
{% elif order.status and order.status.is_negative_end and order.amount_paid > 0 %}
<span class="badge bg-warning text-dark" title="Требуется возврат: {{ order.amount_paid|floatformat:2 }} руб.">
<i class="bi bi-exclamation-triangle"></i> Возврат
</span>
{% elif order.amount_paid > 0 %}
<span class="badge bg-warning">
<i class="bi bi-exclamation-circle"></i> Частично ({{ order.amount_paid|floatformat:2 }} руб.)
</span>
{% else %} {% else %}
<span class="badge bg-danger">Не оплачено</span> <span class="badge bg-danger">
<i class="bi bi-x-circle"></i> Не оплачено
</span>
{% endif %} {% endif %}
</td> </td>
<td><strong>{{ order.total_amount|floatformat:2 }} руб.</strong></td> <td><strong>{{ order.total_amount|floatformat:2 }} руб.</strong></td>
@@ -330,12 +363,23 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if order.amount_due > 0 %} {% if order.status and order.status.is_negative_end %}
<span class="text-muted"></span>
{% elif order.amount_due > 0 %}
<span class="text-danger fw-bold">{{ order.amount_due|floatformat:2 }} руб.</span> <span class="text-danger fw-bold">{{ order.amount_due|floatformat:2 }} руб.</span>
{% else %} {% else %}
<span class="text-success">0.00 руб.</span> <span class="text-success">0.00 руб.</span>
{% endif %} {% endif %}
</td> </td>
<td>
{% if order.status and order.status.is_negative_end and order.amount_paid > 0 %}
<span class="badge bg-warning text-dark" title="Требуется возврат">
<i class="bi bi-exclamation-triangle"></i> {{ order.amount_paid|floatformat:2 }} руб.
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td> <td>
<a href="{% url 'orders:order-detail' order.order_number %}" class="btn btn-sm btn-outline-primary"> <a href="{% url 'orders:order-detail' order.order_number %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>

View File

@@ -87,8 +87,13 @@ def customer_detail(request, pk):
if customer.is_system_customer: if customer.is_system_customer:
return render(request, 'customers/customer_system.html') 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( total_debt=Coalesce(
Sum(Greatest(F('total_amount') - F('amount_paid'), Value(0), output_field=DecimalField())), Sum(Greatest(F('total_amount') - F('amount_paid'), Value(0), output_field=DecimalField())),
Value(0), Value(0),
@@ -96,9 +101,25 @@ def customer_detail(request, pk):
) )
) )
total_debt = total_debt_result['total_debt'] or Decimal('0') 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) # История транзакций кошелька (последние 20)
from .models import WalletTransaction from .models import WalletTransaction
@@ -116,6 +137,7 @@ def customer_detail(request, pk):
'customer': customer, 'customer': customer,
'total_debt': total_debt, 'total_debt': total_debt,
'active_orders_count': active_orders_count, 'active_orders_count': active_orders_count,
'refund_amount': refund_amount,
'wallet_transactions': wallet_transactions, 'wallet_transactions': wallet_transactions,
'orders_page': orders_page, 'orders_page': orders_page,
} }

View File

@@ -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 # URL CONFIGURATION
# ============================================ # ============================================

View File

@@ -329,6 +329,22 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Предупреждение о необходимости возврата -->
{% if order.status and order.status.is_negative_end and order.amount_paid > 0 %}
<hr>
<div class="alert alert-warning mb-0">
<h6 class="alert-heading">
<i class="bi bi-exclamation-triangle-fill"></i> Требуется возврат
</h6>
<p class="mb-0">
Заказ отменён, но клиент внёс оплату: <strong>{{ order.amount_paid|floatformat:2 }} руб.</strong>
</p>
<small class="text-muted d-block mt-2">
<i class="bi bi-info-circle"></i> Создайте возврат через раздел "История транзакций" ниже
</small>
</div>
{% endif %}
</div> </div>
</div> </div>

View File

@@ -153,6 +153,10 @@
<span class="badge bg-success"> <span class="badge bg-success">
<i class="bi bi-check-circle"></i> Оплачен <i class="bi bi-check-circle"></i> Оплачен
</span> </span>
{% elif order.status and order.status.is_negative_end and order.amount_paid > 0 %}
<span class="badge bg-warning text-dark" title="Требуется возврат: {{ order.amount_paid }} руб.">
<i class="bi bi-exclamation-triangle"></i> Возврат
</span>
{% elif order.amount_paid > 0 %} {% elif order.amount_paid > 0 %}
<span class="badge bg-warning"> <span class="badge bg-warning">
<i class="bi bi-exclamation-circle"></i> Частично ({{ order.amount_paid }} руб.) <i class="bi bi-exclamation-circle"></i> Частично ({{ order.amount_paid }} руб.)

View File

@@ -366,6 +366,7 @@ class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Lis
context['item_statuses'] = item_statuses context['item_statuses'] = item_statuses
# Кнопки действий # Кнопки действий
# Проверяем права через has_perm, который использует наш RoleBasedPermissionBackend
action_buttons = [] action_buttons = []
if self.request.user.has_perm('products.add_product'): if self.request.user.has_perm('products.add_product'):

View File

@@ -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

View File

@@ -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