Исправлена ошибка public admin для мультитенантной архитектуры
Проблема: при входе в localhost/admin/ (public схема) возникала ошибка "relation user_roles_userrole does not exist", так как tenant-only таблицы не существуют в public схеме. Решение: - Создан TenantAdminOnlyMixin для скрытия tenant-only моделей от public admin - Применён миксин ко всем ModelAdmin классам в tenant-only приложениях: user_roles, customers, orders, inventory, products - Добавлена проверка _is_public_schema() в RoleBasedPermissionBackend для предотвращения запросов к tenant-only таблицам в public схеме Теперь: - localhost/admin/ показывает только public модели (Client, Domain, User) - shop.localhost/admin/ показывает все модели магазина 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ from django.contrib import admin
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from .models import Customer, WalletTransaction, ContactChannel
|
from .models import Customer, WalletTransaction, ContactChannel
|
||||||
|
from tenants.admin_mixins import TenantAdminOnlyMixin
|
||||||
|
|
||||||
|
|
||||||
class IsSystemCustomerFilter(admin.SimpleListFilter):
|
class IsSystemCustomerFilter(admin.SimpleListFilter):
|
||||||
@@ -23,8 +24,11 @@ class IsSystemCustomerFilter(admin.SimpleListFilter):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Customer)
|
@admin.register(Customer)
|
||||||
class CustomerAdmin(admin.ModelAdmin):
|
class CustomerAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
"""Административный интерфейс для управления клиентами цветочного магазина"""
|
"""
|
||||||
|
Административный интерфейс для управления клиентами цветочного магазина.
|
||||||
|
TenantAdminOnlyMixin - скрывает от public admin (таблица только в tenant схемах).
|
||||||
|
"""
|
||||||
list_display = (
|
list_display = (
|
||||||
'full_name',
|
'full_name',
|
||||||
'email',
|
'email',
|
||||||
@@ -124,8 +128,11 @@ CustomerAdmin.inlines = [ContactChannelInline, WalletTransactionInline]
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(WalletTransaction)
|
@admin.register(WalletTransaction)
|
||||||
class WalletTransactionAdmin(admin.ModelAdmin):
|
class WalletTransactionAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
"""Админка для просмотра всех транзакций кошелька"""
|
"""
|
||||||
|
Админка для просмотра всех транзакций кошелька.
|
||||||
|
TenantAdminOnlyMixin - скрывает от public admin.
|
||||||
|
"""
|
||||||
list_display = ('created_at', 'customer', 'transaction_type', 'amount_display', 'balance_after', 'order', 'created_by')
|
list_display = ('created_at', 'customer', 'transaction_type', 'amount_display', 'balance_after', 'order', 'created_by')
|
||||||
list_filter = ('transaction_type', 'balance_category', 'created_at')
|
list_filter = ('transaction_type', 'balance_category', 'created_at')
|
||||||
search_fields = ('customer__name', 'customer__email', 'customer__phone', 'description')
|
search_fields = ('customer__name', 'customer__email', 'customer__phone', 'description')
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Админка для приложения inventory.
|
||||||
|
Все модели tenant-only, поэтому используют TenantAdminOnlyMixin
|
||||||
|
для скрытия от public admin (localhost/admin/).
|
||||||
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -11,11 +16,12 @@ from inventory.models import (
|
|||||||
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput,
|
IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput,
|
||||||
TransformationOutput, TransferDocument, TransferDocumentItem
|
TransformationOutput, TransferDocument, TransferDocumentItem
|
||||||
)
|
)
|
||||||
|
from tenants.admin_mixins import TenantAdminOnlyMixin
|
||||||
|
|
||||||
|
|
||||||
# ===== SHOWCASE =====
|
# ===== SHOWCASE =====
|
||||||
@admin.register(Showcase)
|
@admin.register(Showcase)
|
||||||
class ShowcaseAdmin(admin.ModelAdmin):
|
class ShowcaseAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('name', 'warehouse', 'is_default', 'is_active', 'created_at')
|
list_display = ('name', 'warehouse', 'is_default', 'is_active', 'created_at')
|
||||||
list_filter = ('is_active', 'is_default', 'warehouse', 'created_at')
|
list_filter = ('is_active', 'is_default', 'warehouse', 'created_at')
|
||||||
search_fields = ('name', 'warehouse__name')
|
search_fields = ('name', 'warehouse__name')
|
||||||
@@ -34,7 +40,7 @@ class ShowcaseAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
# ===== WAREHOUSE =====
|
# ===== WAREHOUSE =====
|
||||||
@admin.register(Warehouse)
|
@admin.register(Warehouse)
|
||||||
class WarehouseAdmin(admin.ModelAdmin):
|
class WarehouseAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('name', 'is_default_display', 'is_active', 'created_at')
|
list_display = ('name', 'is_default_display', 'is_active', 'created_at')
|
||||||
list_filter = ('is_active', 'is_default', 'created_at')
|
list_filter = ('is_active', 'is_default', 'created_at')
|
||||||
search_fields = ('name',)
|
search_fields = ('name',)
|
||||||
@@ -58,7 +64,7 @@ class WarehouseAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
# ===== STOCK BATCH =====
|
# ===== STOCK BATCH =====
|
||||||
@admin.register(StockBatch)
|
@admin.register(StockBatch)
|
||||||
class StockBatchAdmin(admin.ModelAdmin):
|
class StockBatchAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('product', 'warehouse', 'quantity_display', 'cost_price', 'created_at', 'is_active')
|
list_display = ('product', 'warehouse', 'quantity_display', 'cost_price', 'created_at', 'is_active')
|
||||||
list_filter = ('warehouse', 'is_active', 'created_at')
|
list_filter = ('warehouse', 'is_active', 'created_at')
|
||||||
search_fields = ('product__name', 'product__sku', 'warehouse__name')
|
search_fields = ('product__name', 'product__sku', 'warehouse__name')
|
||||||
@@ -104,7 +110,7 @@ class SaleBatchAllocationInline(admin.TabularInline):
|
|||||||
|
|
||||||
# ===== SALE =====
|
# ===== SALE =====
|
||||||
@admin.register(Sale)
|
@admin.register(Sale)
|
||||||
class SaleAdmin(admin.ModelAdmin):
|
class SaleAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('product', 'warehouse', 'quantity', 'sale_price', 'order_display', 'processed_display', 'date')
|
list_display = ('product', 'warehouse', 'quantity', 'sale_price', 'order_display', 'processed_display', 'date')
|
||||||
list_filter = ('warehouse', 'processed', 'date')
|
list_filter = ('warehouse', 'processed', 'date')
|
||||||
search_fields = ('product__name', 'order__order_number')
|
search_fields = ('product__name', 'order__order_number')
|
||||||
@@ -142,7 +148,7 @@ class SaleAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
# ===== WRITE OFF =====
|
# ===== WRITE OFF =====
|
||||||
@admin.register(WriteOff)
|
@admin.register(WriteOff)
|
||||||
class WriteOffAdmin(admin.ModelAdmin):
|
class WriteOffAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('batch', 'quantity', 'reason_display', 'cost_price', 'date')
|
list_display = ('batch', 'quantity', 'reason_display', 'cost_price', 'date')
|
||||||
list_filter = ('reason', 'date', 'batch__warehouse')
|
list_filter = ('reason', 'date', 'batch__warehouse')
|
||||||
search_fields = ('batch__product__name', 'document_number')
|
search_fields = ('batch__product__name', 'document_number')
|
||||||
@@ -176,7 +182,7 @@ class InventoryLineInline(admin.TabularInline):
|
|||||||
|
|
||||||
# ===== INVENTORY =====
|
# ===== INVENTORY =====
|
||||||
@admin.register(Inventory)
|
@admin.register(Inventory)
|
||||||
class InventoryAdmin(admin.ModelAdmin):
|
class InventoryAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('warehouse', 'status_display', 'date', 'conducted_by')
|
list_display = ('warehouse', 'status_display', 'date', 'conducted_by')
|
||||||
list_filter = ('status', 'date', 'warehouse')
|
list_filter = ('status', 'date', 'warehouse')
|
||||||
search_fields = ('warehouse__name', 'conducted_by')
|
search_fields = ('warehouse__name', 'conducted_by')
|
||||||
@@ -234,7 +240,7 @@ class InventoryAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
# ===== RESERVATION =====
|
# ===== RESERVATION =====
|
||||||
@admin.register(Reservation)
|
@admin.register(Reservation)
|
||||||
class ReservationAdmin(admin.ModelAdmin):
|
class ReservationAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('product', 'warehouse', 'quantity', 'status_display', 'context_info', 'reserved_at')
|
list_display = ('product', 'warehouse', 'quantity', 'status_display', 'context_info', 'reserved_at')
|
||||||
list_filter = ('status', 'reserved_at', 'warehouse', 'showcase')
|
list_filter = ('status', 'reserved_at', 'warehouse', 'showcase')
|
||||||
search_fields = ('product__name', 'order_item__order__order_number', 'showcase__name')
|
search_fields = ('product__name', 'order_item__order__order_number', 'showcase__name')
|
||||||
@@ -279,7 +285,7 @@ class ReservationAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
# ===== STOCK =====
|
# ===== STOCK =====
|
||||||
@admin.register(Stock)
|
@admin.register(Stock)
|
||||||
class StockAdmin(admin.ModelAdmin):
|
class StockAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('product', 'warehouse', 'quantity_available', 'quantity_reserved', 'quantity_free', 'updated_at')
|
list_display = ('product', 'warehouse', 'quantity_available', 'quantity_reserved', 'quantity_free', 'updated_at')
|
||||||
list_filter = ('warehouse', 'updated_at')
|
list_filter = ('warehouse', 'updated_at')
|
||||||
search_fields = ('product__name', 'product__sku', 'warehouse__name')
|
search_fields = ('product__name', 'product__sku', 'warehouse__name')
|
||||||
@@ -305,7 +311,7 @@ class WriteOffDocumentItemInline(admin.TabularInline):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(WriteOffDocument)
|
@admin.register(WriteOffDocument)
|
||||||
class WriteOffDocumentAdmin(admin.ModelAdmin):
|
class WriteOffDocumentAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('document_number', 'warehouse', 'status_display', 'date', 'items_count', 'total_quantity_display', 'created_by', 'created_at')
|
list_display = ('document_number', 'warehouse', 'status_display', 'date', 'items_count', 'total_quantity_display', 'created_by', 'created_at')
|
||||||
list_filter = ('status', 'warehouse', 'date', 'created_at')
|
list_filter = ('status', 'warehouse', 'date', 'created_at')
|
||||||
search_fields = ('document_number', 'warehouse__name')
|
search_fields = ('document_number', 'warehouse__name')
|
||||||
@@ -346,7 +352,7 @@ class WriteOffDocumentAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(WriteOffDocumentItem)
|
@admin.register(WriteOffDocumentItem)
|
||||||
class WriteOffDocumentItemAdmin(admin.ModelAdmin):
|
class WriteOffDocumentItemAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('document', 'product', 'quantity', 'reason', 'created_at')
|
list_display = ('document', 'product', 'quantity', 'reason', 'created_at')
|
||||||
list_filter = ('reason', 'document__status', 'created_at')
|
list_filter = ('reason', 'document__status', 'created_at')
|
||||||
search_fields = ('product__name', 'document__document_number')
|
search_fields = ('product__name', 'document__document_number')
|
||||||
@@ -362,7 +368,7 @@ class IncomingDocumentItemInline(admin.TabularInline):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(IncomingDocument)
|
@admin.register(IncomingDocument)
|
||||||
class IncomingDocumentAdmin(admin.ModelAdmin):
|
class IncomingDocumentAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('document_number', 'warehouse', 'status_display', 'receipt_type_display', 'date', 'items_count', 'total_quantity_display', 'created_by', 'created_at')
|
list_display = ('document_number', 'warehouse', 'status_display', 'receipt_type_display', 'date', 'items_count', 'total_quantity_display', 'created_by', 'created_at')
|
||||||
list_filter = ('status', 'warehouse', 'receipt_type', 'date', 'created_at')
|
list_filter = ('status', 'warehouse', 'receipt_type', 'date', 'created_at')
|
||||||
search_fields = ('document_number', 'warehouse__name', 'supplier_name')
|
search_fields = ('document_number', 'warehouse__name', 'supplier_name')
|
||||||
@@ -416,7 +422,7 @@ class IncomingDocumentAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(IncomingDocumentItem)
|
@admin.register(IncomingDocumentItem)
|
||||||
class IncomingDocumentItemAdmin(admin.ModelAdmin):
|
class IncomingDocumentItemAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('document', 'product', 'quantity', 'cost_price', 'total_cost_display', 'created_at')
|
list_display = ('document', 'product', 'quantity', 'cost_price', 'total_cost_display', 'created_at')
|
||||||
list_filter = ('document__status', 'document__receipt_type', 'created_at')
|
list_filter = ('document__status', 'document__receipt_type', 'created_at')
|
||||||
search_fields = ('product__name', 'document__document_number')
|
search_fields = ('product__name', 'document__document_number')
|
||||||
@@ -445,7 +451,7 @@ class TransformationOutputInline(admin.TabularInline):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Transformation)
|
@admin.register(Transformation)
|
||||||
class TransformationAdmin(admin.ModelAdmin):
|
class TransformationAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ['document_number', 'warehouse', 'status_display', 'date', 'employee', 'inputs_count', 'outputs_count']
|
list_display = ['document_number', 'warehouse', 'status_display', 'date', 'employee', 'inputs_count', 'outputs_count']
|
||||||
list_filter = ['status', 'warehouse', 'date']
|
list_filter = ['status', 'warehouse', 'date']
|
||||||
search_fields = ['document_number', 'comment']
|
search_fields = ['document_number', 'comment']
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Админка для приложения orders.
|
||||||
|
Все модели tenant-only, поэтому используют TenantAdminOnlyMixin
|
||||||
|
для скрытия от public admin (localhost/admin/).
|
||||||
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus, Recipient, Delivery
|
from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus, Recipient, Delivery
|
||||||
|
from tenants.admin_mixins import TenantAdminOnlyMixin
|
||||||
|
|
||||||
|
|
||||||
class TransactionInline(admin.TabularInline):
|
class TransactionInline(admin.TabularInline):
|
||||||
@@ -44,10 +50,8 @@ class DeliveryInline(admin.StackedInline):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Order)
|
@admin.register(Order)
|
||||||
class OrderAdmin(admin.ModelAdmin):
|
class OrderAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
"""
|
"""Админ-панель для управления заказами."""
|
||||||
Админ-панель для управления заказами.
|
|
||||||
"""
|
|
||||||
list_display = [
|
list_display = [
|
||||||
'order_number',
|
'order_number',
|
||||||
'customer',
|
'customer',
|
||||||
@@ -150,7 +154,7 @@ class OrderAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Transaction)
|
@admin.register(Transaction)
|
||||||
class TransactionAdmin(admin.ModelAdmin):
|
class TransactionAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
"""
|
"""
|
||||||
Админ-панель для управления транзакциями.
|
Админ-панель для управления транзакциями.
|
||||||
"""
|
"""
|
||||||
@@ -189,7 +193,7 @@ class TransactionAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(OrderItem)
|
@admin.register(OrderItem)
|
||||||
class OrderItemAdmin(admin.ModelAdmin):
|
class OrderItemAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
"""
|
"""
|
||||||
Админ-панель для управления позициями заказов.
|
Админ-панель для управления позициями заказов.
|
||||||
"""
|
"""
|
||||||
@@ -232,7 +236,7 @@ class OrderItemAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Address)
|
@admin.register(Address)
|
||||||
class AddressAdmin(admin.ModelAdmin):
|
class AddressAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
"""
|
"""
|
||||||
Админ-панель для управления адресами доставки заказов.
|
Админ-панель для управления адресами доставки заказов.
|
||||||
"""
|
"""
|
||||||
@@ -276,7 +280,7 @@ class AddressAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Recipient)
|
@admin.register(Recipient)
|
||||||
class RecipientAdmin(admin.ModelAdmin):
|
class RecipientAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
"""
|
"""
|
||||||
Админ-панель для управления получателями заказов.
|
Админ-панель для управления получателями заказов.
|
||||||
"""
|
"""
|
||||||
@@ -309,7 +313,7 @@ class RecipientAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(OrderStatus)
|
@admin.register(OrderStatus)
|
||||||
class OrderStatusAdmin(admin.ModelAdmin):
|
class OrderStatusAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
"""
|
"""
|
||||||
Админ-панель для управления статусами заказов.
|
Админ-панель для управления статусами заказов.
|
||||||
"""
|
"""
|
||||||
@@ -406,7 +410,7 @@ class OrderStatusAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(PaymentMethod)
|
@admin.register(PaymentMethod)
|
||||||
class PaymentMethodAdmin(admin.ModelAdmin):
|
class PaymentMethodAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
"""
|
"""
|
||||||
Админ-панель для управления способами оплаты.
|
Админ-панель для управления способами оплаты.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Админка для приложения products.
|
||||||
|
Все модели tenant-only, поэтому используют TenantAdminOnlyMixin
|
||||||
|
для скрытия от public admin (localhost/admin/).
|
||||||
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -14,6 +19,7 @@ from .admin_displays import (
|
|||||||
format_photo_inline_quality,
|
format_photo_inline_quality,
|
||||||
format_photo_preview_with_quality,
|
format_photo_preview_with_quality,
|
||||||
)
|
)
|
||||||
|
from tenants.admin_mixins import TenantAdminOnlyMixin
|
||||||
|
|
||||||
|
|
||||||
class DeletedFilter(admin.SimpleListFilter):
|
class DeletedFilter(admin.SimpleListFilter):
|
||||||
@@ -292,7 +298,7 @@ def disable_delete_selected(admin_class):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(ProductVariantGroup)
|
@admin.register(ProductVariantGroup)
|
||||||
class ProductVariantGroupAdmin(admin.ModelAdmin):
|
class ProductVariantGroupAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ['name', 'get_products_count', 'created_at']
|
list_display = ['name', 'get_products_count', 'created_at']
|
||||||
search_fields = ['name', 'description']
|
search_fields = ['name', 'description']
|
||||||
list_filter = ['created_at']
|
list_filter = ['created_at']
|
||||||
@@ -303,7 +309,7 @@ class ProductVariantGroupAdmin(admin.ModelAdmin):
|
|||||||
get_products_count.short_description = 'Товаров'
|
get_products_count.short_description = 'Товаров'
|
||||||
|
|
||||||
|
|
||||||
class ProductCategoryAdmin(admin.ModelAdmin):
|
class ProductCategoryAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('photo_with_quality', 'name', 'sku', 'slug', 'parent', 'is_active', 'get_deleted_status')
|
list_display = ('photo_with_quality', 'name', 'sku', 'slug', 'parent', 'is_active', 'get_deleted_status')
|
||||||
list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, 'parent')
|
list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, 'parent')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
@@ -376,14 +382,14 @@ class ProductCategoryAdmin(admin.ModelAdmin):
|
|||||||
photo_preview_large.short_description = "Превью основного фото"
|
photo_preview_large.short_description = "Превью основного фото"
|
||||||
|
|
||||||
|
|
||||||
class ProductTagAdmin(admin.ModelAdmin):
|
class ProductTagAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('name', 'slug', 'is_active')
|
list_display = ('name', 'slug', 'is_active')
|
||||||
list_filter = ('is_active',)
|
list_filter = ('is_active',)
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
search_fields = ('name',)
|
search_fields = ('name',)
|
||||||
|
|
||||||
|
|
||||||
class ProductAdmin(admin.ModelAdmin):
|
class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('photo_with_quality', 'name', 'sku', 'get_categories_display', 'cost_price', 'price', 'sale_price', 'get_variant_groups_display', 'get_status_display')
|
list_display = ('photo_with_quality', 'name', 'sku', 'get_categories_display', 'cost_price', 'price', 'sale_price', 'get_variant_groups_display', 'get_status_display')
|
||||||
list_filter = (DeletedFilter, QualityLevelFilter, 'categories', 'tags', 'variant_groups')
|
list_filter = (DeletedFilter, QualityLevelFilter, 'categories', 'tags', 'variant_groups')
|
||||||
search_fields = ('name', 'sku', 'description', 'search_keywords')
|
search_fields = ('name', 'sku', 'description', 'search_keywords')
|
||||||
@@ -576,7 +582,7 @@ class ProductAdmin(admin.ModelAdmin):
|
|||||||
photo_preview_large.short_description = "Превью основного фото"
|
photo_preview_large.short_description = "Превью основного фото"
|
||||||
|
|
||||||
|
|
||||||
class ProductKitAdmin(admin.ModelAdmin):
|
class ProductKitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ('photo_with_quality', 'name', 'slug', 'get_categories_display', 'get_price_display', 'is_temporary', 'get_order_link', 'get_status_display')
|
list_display = ('photo_with_quality', 'name', 'slug', 'get_categories_display', 'get_price_display', 'is_temporary', 'get_order_link', 'get_status_display')
|
||||||
list_filter = (DeletedFilter, 'is_temporary', QualityLevelFilter, 'categories', 'tags')
|
list_filter = (DeletedFilter, 'is_temporary', QualityLevelFilter, 'categories', 'tags')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
@@ -836,7 +842,7 @@ class ProductCategoryAdminWithPhotos(ProductCategoryAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(KitItem)
|
@admin.register(KitItem)
|
||||||
class KitItemAdmin(admin.ModelAdmin):
|
class KitItemAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ['__str__', 'kit', 'get_type', 'quantity', 'has_priorities']
|
list_display = ['__str__', 'kit', 'get_type', 'quantity', 'has_priorities']
|
||||||
list_filter = ['kit']
|
list_filter = ['kit']
|
||||||
list_select_related = ['kit', 'product', 'variant_group']
|
list_select_related = ['kit', 'product', 'variant_group']
|
||||||
@@ -856,7 +862,7 @@ class KitItemAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(SKUCounter)
|
@admin.register(SKUCounter)
|
||||||
class SKUCounterAdmin(admin.ModelAdmin):
|
class SKUCounterAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ['counter_type', 'current_value', 'get_next_preview']
|
list_display = ['counter_type', 'current_value', 'get_next_preview']
|
||||||
list_filter = ['counter_type']
|
list_filter = ['counter_type']
|
||||||
readonly_fields = ['get_next_preview']
|
readonly_fields = ['get_next_preview']
|
||||||
@@ -879,7 +885,7 @@ class SKUCounterAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(CostPriceHistory)
|
@admin.register(CostPriceHistory)
|
||||||
class CostPriceHistoryAdmin(admin.ModelAdmin):
|
class CostPriceHistoryAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
list_display = ['product', 'get_price_change', 'reason', 'created_at']
|
list_display = ['product', 'get_price_change', 'reason', 'created_at']
|
||||||
list_filter = ['reason', 'created_at', 'product']
|
list_filter = ['reason', 'created_at', 'product']
|
||||||
search_fields = ['product__name', 'product__sku', 'notes']
|
search_fields = ['product__name', 'product__sku', 'notes']
|
||||||
@@ -965,7 +971,7 @@ class ConfigurableProductAttributeInline(admin.TabularInline):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(ConfigurableProduct)
|
@admin.register(ConfigurableProduct)
|
||||||
class ConfigurableProductAdmin(admin.ModelAdmin):
|
class ConfigurableProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
"""Админка для вариативных товаров"""
|
"""Админка для вариативных товаров"""
|
||||||
list_display = ('name', 'sku', 'status', 'get_options_count', 'created_at')
|
list_display = ('name', 'sku', 'status', 'get_options_count', 'created_at')
|
||||||
list_filter = ('status', 'created_at')
|
list_filter = ('status', 'created_at')
|
||||||
|
|||||||
91
myproject/tenants/admin_mixins.py
Normal file
91
myproject/tenants/admin_mixins.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Миксины для Django Admin в мультитенантной архитектуре.
|
||||||
|
|
||||||
|
ЗАЧЕМ ЭТО НУЖНО:
|
||||||
|
================
|
||||||
|
В django-tenants есть две схемы БД:
|
||||||
|
- public: общая схема (тенанты, домены, пользователи)
|
||||||
|
- tenant: отдельная схема для каждого магазина (товары, заказы, клиенты)
|
||||||
|
|
||||||
|
Проблема: Django Admin регистрирует ВСЕ модели глобально.
|
||||||
|
Когда суперадмин заходит на localhost/admin/ (public схема),
|
||||||
|
Django пытается показать sidebar со ВСЕМИ моделями, включая
|
||||||
|
tenant-only модели (Order, Product, UserRole).
|
||||||
|
|
||||||
|
Но таблицы этих моделей существуют только в схемах тенантов!
|
||||||
|
В public схеме их нет -> ошибка "relation does not exist".
|
||||||
|
|
||||||
|
РЕШЕНИЕ:
|
||||||
|
========
|
||||||
|
Миксин TenantAdminOnlyMixin скрывает модель от админки,
|
||||||
|
если мы находимся в public схеме. Модель просто не показывается
|
||||||
|
в sidebar и недоступна по URL.
|
||||||
|
|
||||||
|
ИСПОЛЬЗОВАНИЕ:
|
||||||
|
==============
|
||||||
|
from tenants.admin_mixins import TenantAdminOnlyMixin
|
||||||
|
|
||||||
|
@admin.register(Order)
|
||||||
|
class OrderAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
|
...
|
||||||
|
|
||||||
|
Теперь Order будет виден только в админке тенанта (shop.localhost/admin/),
|
||||||
|
но не в public админке (localhost/admin/).
|
||||||
|
"""
|
||||||
|
from django_tenants.utils import get_public_schema_name
|
||||||
|
|
||||||
|
|
||||||
|
class TenantAdminOnlyMixin:
|
||||||
|
"""
|
||||||
|
Миксин для скрытия tenant-only моделей от public admin.
|
||||||
|
|
||||||
|
Как работает:
|
||||||
|
- has_module_permission() вызывается Django для проверки,
|
||||||
|
показывать ли модель в sidebar админки
|
||||||
|
- Мы проверяем текущую схему БД
|
||||||
|
- Если это public схема -> возвращаем False (скрыть)
|
||||||
|
- Если это схема тенанта -> возвращаем True (показать)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def has_module_permission(self, request):
|
||||||
|
"""
|
||||||
|
Скрыть модуль (группу моделей) от public schema админки.
|
||||||
|
|
||||||
|
Вызывается при отрисовке sidebar и при доступе к URL модели.
|
||||||
|
"""
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
# Получаем имя public схемы (обычно 'public')
|
||||||
|
public_schema = get_public_schema_name()
|
||||||
|
|
||||||
|
# Если мы в public схеме - скрываем модель
|
||||||
|
if connection.schema_name == public_schema:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# В схеме тенанта - показываем (используем стандартную проверку)
|
||||||
|
return super().has_module_permission(request)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicAdminOnlyMixin:
|
||||||
|
"""
|
||||||
|
Обратный миксин - показывать модель ТОЛЬКО в public admin.
|
||||||
|
|
||||||
|
Полезно для моделей типа Client, Domain, TenantRegistration,
|
||||||
|
которые должны быть доступны только суперадмину на главном домене.
|
||||||
|
|
||||||
|
Примечание: обычно не нужен, так как эти модели и так
|
||||||
|
существуют только в public схеме.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def has_module_permission(self, request):
|
||||||
|
"""Показать модуль только в public schema админке."""
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
public_schema = get_public_schema_name()
|
||||||
|
|
||||||
|
# Показываем только в public схеме
|
||||||
|
if connection.schema_name == public_schema:
|
||||||
|
return super().has_module_permission(request)
|
||||||
|
|
||||||
|
return False
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from user_roles.models import Role, UserRole
|
from user_roles.models import Role, UserRole
|
||||||
from user_roles.mixins import OwnerOnlyAdminMixin
|
from user_roles.mixins import OwnerOnlyAdminMixin
|
||||||
|
from tenants.admin_mixins import TenantAdminOnlyMixin
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Role)
|
@admin.register(Role)
|
||||||
class RoleAdmin(admin.ModelAdmin):
|
class RoleAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Админка ролей.
|
||||||
|
TenantAdminOnlyMixin - скрывает от public admin (localhost/admin/),
|
||||||
|
так как таблица Role существует только в схемах тенантов.
|
||||||
|
"""
|
||||||
list_display = ['code', 'name', 'is_system']
|
list_display = ['code', 'name', 'is_system']
|
||||||
list_filter = ['is_system']
|
list_filter = ['is_system']
|
||||||
search_fields = ['code', 'name']
|
search_fields = ['code', 'name']
|
||||||
@@ -18,10 +24,12 @@ class RoleAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(UserRole)
|
@admin.register(UserRole)
|
||||||
class UserRoleAdmin(OwnerOnlyAdminMixin, admin.ModelAdmin):
|
class UserRoleAdmin(TenantAdminOnlyMixin, OwnerOnlyAdminMixin, admin.ModelAdmin):
|
||||||
"""
|
"""
|
||||||
Админка ролей пользователей.
|
Админка ролей пользователей.
|
||||||
Доступна только владельцу.
|
|
||||||
|
TenantAdminOnlyMixin - скрывает от public admin (таблица только в tenant схемах)
|
||||||
|
OwnerOnlyAdminMixin - доступна только владельцу магазина
|
||||||
|
|
||||||
ВАЖНО: UserRole изолирован по тенантам автоматически через django-tenants,
|
ВАЖНО: UserRole изолирован по тенантам автоматически через django-tenants,
|
||||||
поэтому владелец видит только пользователей своего магазина!
|
поэтому владелец видит только пользователей своего магазина!
|
||||||
|
|||||||
@@ -4,12 +4,25 @@
|
|||||||
ВАЖНО: Этот backend НЕ использует таблицы Django permissions из public schema!
|
ВАЖНО: Этот backend НЕ использует таблицы Django permissions из public schema!
|
||||||
Он только эмулирует API has_perm(), читая роли из текущей tenant schema.
|
Он только эмулирует API has_perm(), читая роли из текущей tenant schema.
|
||||||
Это безопасно для мультитенантной архитектуры.
|
Это безопасно для мультитенантной архитектуры.
|
||||||
|
|
||||||
|
ВАЖНО: Backend проверяет текущую схему перед обращением к tenant-only таблицам.
|
||||||
|
В public схеме ролевые проверки пропускаются (fallback на стандартные Django permissions).
|
||||||
"""
|
"""
|
||||||
from django.contrib.auth.backends import ModelBackend
|
from django.contrib.auth.backends import ModelBackend
|
||||||
|
from django.db import connection
|
||||||
|
from django_tenants.utils import get_public_schema_name
|
||||||
from user_roles.services import RoleService
|
from user_roles.services import RoleService
|
||||||
from user_roles.models import Role
|
from user_roles.models import Role
|
||||||
|
|
||||||
|
|
||||||
|
def _is_public_schema():
|
||||||
|
"""
|
||||||
|
Проверяет, находимся ли мы в public схеме.
|
||||||
|
В public схеме нет таблиц tenant-only моделей (UserRole, Role).
|
||||||
|
"""
|
||||||
|
return connection.schema_name == get_public_schema_name()
|
||||||
|
|
||||||
|
|
||||||
class RoleBasedPermissionBackend(ModelBackend):
|
class RoleBasedPermissionBackend(ModelBackend):
|
||||||
"""
|
"""
|
||||||
Backend, который предоставляет права на основе роли пользователя в текущем тенанте.
|
Backend, который предоставляет права на основе роли пользователя в текущем тенанте.
|
||||||
@@ -86,6 +99,11 @@ class RoleBasedPermissionBackend(ModelBackend):
|
|||||||
if user_obj.is_superuser:
|
if user_obj.is_superuser:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# ВАЖНО: В public схеме нет таблиц UserRole/Role!
|
||||||
|
# Используем только стандартные Django permissions.
|
||||||
|
if _is_public_schema():
|
||||||
|
return False
|
||||||
|
|
||||||
# Получаем роль пользователя в текущем тенанте
|
# Получаем роль пользователя в текущем тенанте
|
||||||
# ВАЖНО: RoleService работает с текущей tenant schema!
|
# ВАЖНО: RoleService работает с текущей tenant schema!
|
||||||
user_role = RoleService.get_user_role(user_obj)
|
user_role = RoleService.get_user_role(user_obj)
|
||||||
@@ -132,6 +150,11 @@ class RoleBasedPermissionBackend(ModelBackend):
|
|||||||
if user_obj.is_superuser:
|
if user_obj.is_superuser:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# ВАЖНО: В public схеме нет таблиц UserRole/Role!
|
||||||
|
# Используем только стандартные Django permissions.
|
||||||
|
if _is_public_schema():
|
||||||
|
return False
|
||||||
|
|
||||||
# Получаем роль пользователя в текущем тенанте
|
# Получаем роль пользователя в текущем тенанте
|
||||||
user_role = RoleService.get_user_role(user_obj)
|
user_role = RoleService.get_user_role(user_obj)
|
||||||
if not user_role:
|
if not user_role:
|
||||||
|
|||||||
Reference in New Issue
Block a user