diff --git a/myproject/customers/admin.py b/myproject/customers/admin.py index 9326883..c7a2a5e 100644 --- a/myproject/customers/admin.py +++ b/myproject/customers/admin.py @@ -2,6 +2,7 @@ from django.contrib import admin from django.db import models from django.utils.html import format_html from .models import Customer, WalletTransaction, ContactChannel +from tenants.admin_mixins import TenantAdminOnlyMixin class IsSystemCustomerFilter(admin.SimpleListFilter): @@ -23,8 +24,11 @@ class IsSystemCustomerFilter(admin.SimpleListFilter): @admin.register(Customer) -class CustomerAdmin(admin.ModelAdmin): - """Административный интерфейс для управления клиентами цветочного магазина""" +class CustomerAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): + """ + Административный интерфейс для управления клиентами цветочного магазина. + TenantAdminOnlyMixin - скрывает от public admin (таблица только в tenant схемах). + """ list_display = ( 'full_name', 'email', @@ -124,8 +128,11 @@ CustomerAdmin.inlines = [ContactChannelInline, WalletTransactionInline] @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_filter = ('transaction_type', 'balance_category', 'created_at') search_fields = ('customer__name', 'customer__email', 'customer__phone', 'description') diff --git a/myproject/inventory/admin.py b/myproject/inventory/admin.py index 3519fda..96a6fe6 100644 --- a/myproject/inventory/admin.py +++ b/myproject/inventory/admin.py @@ -1,3 +1,8 @@ +""" +Админка для приложения inventory. +Все модели tenant-only, поэтому используют TenantAdminOnlyMixin +для скрытия от public admin (localhost/admin/). +""" from django.contrib import admin from django.utils.html import format_html from django.urls import reverse @@ -11,11 +16,12 @@ from inventory.models import ( IncomingDocument, IncomingDocumentItem, Transformation, TransformationInput, TransformationOutput, TransferDocument, TransferDocumentItem ) +from tenants.admin_mixins import TenantAdminOnlyMixin # ===== SHOWCASE ===== @admin.register(Showcase) -class ShowcaseAdmin(admin.ModelAdmin): +class ShowcaseAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): list_display = ('name', 'warehouse', 'is_default', 'is_active', 'created_at') list_filter = ('is_active', 'is_default', 'warehouse', 'created_at') search_fields = ('name', 'warehouse__name') @@ -34,7 +40,7 @@ class ShowcaseAdmin(admin.ModelAdmin): # ===== WAREHOUSE ===== @admin.register(Warehouse) -class WarehouseAdmin(admin.ModelAdmin): +class WarehouseAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): list_display = ('name', 'is_default_display', 'is_active', 'created_at') list_filter = ('is_active', 'is_default', 'created_at') search_fields = ('name',) @@ -58,7 +64,7 @@ class WarehouseAdmin(admin.ModelAdmin): # ===== STOCK BATCH ===== @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_filter = ('warehouse', 'is_active', 'created_at') search_fields = ('product__name', 'product__sku', 'warehouse__name') @@ -104,7 +110,7 @@ class SaleBatchAllocationInline(admin.TabularInline): # ===== 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_filter = ('warehouse', 'processed', 'date') search_fields = ('product__name', 'order__order_number') @@ -142,7 +148,7 @@ class SaleAdmin(admin.ModelAdmin): # ===== WRITE OFF ===== @admin.register(WriteOff) -class WriteOffAdmin(admin.ModelAdmin): +class WriteOffAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): list_display = ('batch', 'quantity', 'reason_display', 'cost_price', 'date') list_filter = ('reason', 'date', 'batch__warehouse') search_fields = ('batch__product__name', 'document_number') @@ -176,7 +182,7 @@ class InventoryLineInline(admin.TabularInline): # ===== INVENTORY ===== @admin.register(Inventory) -class InventoryAdmin(admin.ModelAdmin): +class InventoryAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): list_display = ('warehouse', 'status_display', 'date', 'conducted_by') list_filter = ('status', 'date', 'warehouse') search_fields = ('warehouse__name', 'conducted_by') @@ -234,7 +240,7 @@ class InventoryAdmin(admin.ModelAdmin): # ===== 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_filter = ('status', 'reserved_at', 'warehouse', 'showcase') search_fields = ('product__name', 'order_item__order__order_number', 'showcase__name') @@ -279,7 +285,7 @@ class ReservationAdmin(admin.ModelAdmin): # ===== 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_filter = ('warehouse', 'updated_at') search_fields = ('product__name', 'product__sku', 'warehouse__name') @@ -305,7 +311,7 @@ class WriteOffDocumentItemInline(admin.TabularInline): @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_filter = ('status', 'warehouse', 'date', 'created_at') search_fields = ('document_number', 'warehouse__name') @@ -346,7 +352,7 @@ class WriteOffDocumentAdmin(admin.ModelAdmin): @admin.register(WriteOffDocumentItem) -class WriteOffDocumentItemAdmin(admin.ModelAdmin): +class WriteOffDocumentItemAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): list_display = ('document', 'product', 'quantity', 'reason', 'created_at') list_filter = ('reason', 'document__status', 'created_at') search_fields = ('product__name', 'document__document_number') @@ -362,7 +368,7 @@ class IncomingDocumentItemInline(admin.TabularInline): @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_filter = ('status', 'warehouse', 'receipt_type', 'date', 'created_at') search_fields = ('document_number', 'warehouse__name', 'supplier_name') @@ -416,7 +422,7 @@ class IncomingDocumentAdmin(admin.ModelAdmin): @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_filter = ('document__status', 'document__receipt_type', 'created_at') search_fields = ('product__name', 'document__document_number') @@ -445,7 +451,7 @@ class TransformationOutputInline(admin.TabularInline): @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_filter = ['status', 'warehouse', 'date'] search_fields = ['document_number', 'comment'] diff --git a/myproject/orders/admin.py b/myproject/orders/admin.py index dbdb034..fc3faa1 100644 --- a/myproject/orders/admin.py +++ b/myproject/orders/admin.py @@ -1,7 +1,13 @@ # -*- coding: utf-8 -*- +""" +Админка для приложения orders. +Все модели tenant-only, поэтому используют TenantAdminOnlyMixin +для скрытия от public admin (localhost/admin/). +""" from django.contrib import admin from django.utils.html import format_html from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus, Recipient, Delivery +from tenants.admin_mixins import TenantAdminOnlyMixin class TransactionInline(admin.TabularInline): @@ -44,10 +50,8 @@ class DeliveryInline(admin.StackedInline): @admin.register(Order) -class OrderAdmin(admin.ModelAdmin): - """ - Админ-панель для управления заказами. - """ +class OrderAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): + """Админ-панель для управления заказами.""" list_display = [ 'order_number', 'customer', @@ -150,7 +154,7 @@ class OrderAdmin(admin.ModelAdmin): @admin.register(Transaction) -class TransactionAdmin(admin.ModelAdmin): +class TransactionAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): """ Админ-панель для управления транзакциями. """ @@ -189,7 +193,7 @@ class TransactionAdmin(admin.ModelAdmin): @admin.register(OrderItem) -class OrderItemAdmin(admin.ModelAdmin): +class OrderItemAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): """ Админ-панель для управления позициями заказов. """ @@ -232,7 +236,7 @@ class OrderItemAdmin(admin.ModelAdmin): @admin.register(Address) -class AddressAdmin(admin.ModelAdmin): +class AddressAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): """ Админ-панель для управления адресами доставки заказов. """ @@ -276,7 +280,7 @@ class AddressAdmin(admin.ModelAdmin): @admin.register(Recipient) -class RecipientAdmin(admin.ModelAdmin): +class RecipientAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): """ Админ-панель для управления получателями заказов. """ @@ -309,7 +313,7 @@ class RecipientAdmin(admin.ModelAdmin): @admin.register(OrderStatus) -class OrderStatusAdmin(admin.ModelAdmin): +class OrderStatusAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): """ Админ-панель для управления статусами заказов. """ @@ -406,7 +410,7 @@ class OrderStatusAdmin(admin.ModelAdmin): @admin.register(PaymentMethod) -class PaymentMethodAdmin(admin.ModelAdmin): +class PaymentMethodAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): """ Админ-панель для управления способами оплаты. """ diff --git a/myproject/products/admin.py b/myproject/products/admin.py index c9d67a0..e3abfbc 100644 --- a/myproject/products/admin.py +++ b/myproject/products/admin.py @@ -1,3 +1,8 @@ +""" +Админка для приложения products. +Все модели tenant-only, поэтому используют TenantAdminOnlyMixin +для скрытия от public admin (localhost/admin/). +""" from django.contrib import admin from django.utils.html import format_html from django.utils import timezone @@ -14,6 +19,7 @@ from .admin_displays import ( format_photo_inline_quality, format_photo_preview_with_quality, ) +from tenants.admin_mixins import TenantAdminOnlyMixin class DeletedFilter(admin.SimpleListFilter): @@ -292,7 +298,7 @@ def disable_delete_selected(admin_class): @admin.register(ProductVariantGroup) -class ProductVariantGroupAdmin(admin.ModelAdmin): +class ProductVariantGroupAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): list_display = ['name', 'get_products_count', 'created_at'] search_fields = ['name', 'description'] list_filter = ['created_at'] @@ -303,7 +309,7 @@ class ProductVariantGroupAdmin(admin.ModelAdmin): 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_filter = (DeletedFilter, 'is_active', QualityLevelFilter, 'parent') prepopulated_fields = {'slug': ('name',)} @@ -376,14 +382,14 @@ class ProductCategoryAdmin(admin.ModelAdmin): photo_preview_large.short_description = "Превью основного фото" -class ProductTagAdmin(admin.ModelAdmin): +class ProductTagAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): list_display = ('name', 'slug', 'is_active') list_filter = ('is_active',) prepopulated_fields = {'slug': ('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_filter = (DeletedFilter, QualityLevelFilter, 'categories', 'tags', 'variant_groups') search_fields = ('name', 'sku', 'description', 'search_keywords') @@ -576,7 +582,7 @@ class ProductAdmin(admin.ModelAdmin): 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_filter = (DeletedFilter, 'is_temporary', QualityLevelFilter, 'categories', 'tags') prepopulated_fields = {'slug': ('name',)} @@ -836,7 +842,7 @@ class ProductCategoryAdminWithPhotos(ProductCategoryAdmin): @admin.register(KitItem) -class KitItemAdmin(admin.ModelAdmin): +class KitItemAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): list_display = ['__str__', 'kit', 'get_type', 'quantity', 'has_priorities'] list_filter = ['kit'] list_select_related = ['kit', 'product', 'variant_group'] @@ -856,7 +862,7 @@ class KitItemAdmin(admin.ModelAdmin): @admin.register(SKUCounter) -class SKUCounterAdmin(admin.ModelAdmin): +class SKUCounterAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): list_display = ['counter_type', 'current_value', 'get_next_preview'] list_filter = ['counter_type'] readonly_fields = ['get_next_preview'] @@ -879,7 +885,7 @@ class SKUCounterAdmin(admin.ModelAdmin): @admin.register(CostPriceHistory) -class CostPriceHistoryAdmin(admin.ModelAdmin): +class CostPriceHistoryAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): list_display = ['product', 'get_price_change', 'reason', 'created_at'] list_filter = ['reason', 'created_at', 'product'] search_fields = ['product__name', 'product__sku', 'notes'] @@ -965,7 +971,7 @@ class ConfigurableProductAttributeInline(admin.TabularInline): @admin.register(ConfigurableProduct) -class ConfigurableProductAdmin(admin.ModelAdmin): +class ConfigurableProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin): """Админка для вариативных товаров""" list_display = ('name', 'sku', 'status', 'get_options_count', 'created_at') list_filter = ('status', 'created_at') diff --git a/myproject/tenants/admin_mixins.py b/myproject/tenants/admin_mixins.py new file mode 100644 index 0000000..ba675cb --- /dev/null +++ b/myproject/tenants/admin_mixins.py @@ -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 diff --git a/myproject/user_roles/admin.py b/myproject/user_roles/admin.py index f233ee8..2e72752 100644 --- a/myproject/user_roles/admin.py +++ b/myproject/user_roles/admin.py @@ -1,10 +1,16 @@ from django.contrib import admin from user_roles.models import Role, UserRole from user_roles.mixins import OwnerOnlyAdminMixin +from tenants.admin_mixins import TenantAdminOnlyMixin @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_filter = ['is_system'] search_fields = ['code', 'name'] @@ -18,10 +24,12 @@ class RoleAdmin(admin.ModelAdmin): @admin.register(UserRole) -class UserRoleAdmin(OwnerOnlyAdminMixin, admin.ModelAdmin): +class UserRoleAdmin(TenantAdminOnlyMixin, OwnerOnlyAdminMixin, admin.ModelAdmin): """ Админка ролей пользователей. - Доступна только владельцу. + + TenantAdminOnlyMixin - скрывает от public admin (таблица только в tenant схемах) + OwnerOnlyAdminMixin - доступна только владельцу магазина ВАЖНО: UserRole изолирован по тенантам автоматически через django-tenants, поэтому владелец видит только пользователей своего магазина! diff --git a/myproject/user_roles/auth_backend.py b/myproject/user_roles/auth_backend.py index 5a481c8..eed988e 100644 --- a/myproject/user_roles/auth_backend.py +++ b/myproject/user_roles/auth_backend.py @@ -4,12 +4,25 @@ ВАЖНО: Этот backend НЕ использует таблицы Django permissions из public schema! Он только эмулирует API has_perm(), читая роли из текущей tenant schema. Это безопасно для мультитенантной архитектуры. + +ВАЖНО: Backend проверяет текущую схему перед обращением к tenant-only таблицам. +В public схеме ролевые проверки пропускаются (fallback на стандартные Django permissions). """ 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.models import Role +def _is_public_schema(): + """ + Проверяет, находимся ли мы в public схеме. + В public схеме нет таблиц tenant-only моделей (UserRole, Role). + """ + return connection.schema_name == get_public_schema_name() + + class RoleBasedPermissionBackend(ModelBackend): """ Backend, который предоставляет права на основе роли пользователя в текущем тенанте. @@ -86,6 +99,11 @@ class RoleBasedPermissionBackend(ModelBackend): if user_obj.is_superuser: return True + # ВАЖНО: В public схеме нет таблиц UserRole/Role! + # Используем только стандартные Django permissions. + if _is_public_schema(): + return False + # Получаем роль пользователя в текущем тенанте # ВАЖНО: RoleService работает с текущей tenant schema! user_role = RoleService.get_user_role(user_obj) @@ -132,6 +150,11 @@ class RoleBasedPermissionBackend(ModelBackend): if user_obj.is_superuser: return True + # ВАЖНО: В public схеме нет таблиц UserRole/Role! + # Используем только стандартные Django permissions. + if _is_public_schema(): + return False + # Получаем роль пользователя в текущем тенанте user_role = RoleService.get_user_role(user_obj) if not user_role: