Исправлена ошибка 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:
2025-12-31 01:05:47 +03:00
parent b59ad725cb
commit eb6a3c1874
7 changed files with 184 additions and 39 deletions

View File

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

View File

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

View File

@@ -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):
""" """
Админ-панель для управления способами оплаты. Админ-панель для управления способами оплаты.
""" """

View File

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

View 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

View File

@@ -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,
поэтому владелец видит только пользователей своего магазина! поэтому владелец видит только пользователей своего магазина!

View File

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