feat: implement user roles system with tenant isolation

Добавлена система ролей пользователей для управления доступом в multi-tenant приложении.

Новые роли:
- Владелец (Owner): полный доступ, управление пользователями
- Менеджер (Manager): управление заказами, клиентами, товарами, складом
- Флорист (Florist): работа с заказами и складскими операциями
- Курьер (Courier): роль создана, права будут определены позже

Архитектура:
- Роли автоматически изолируются по тенантам через django-tenants (TENANT_APPS)
- Не требуется FK на Client/Tenant - изоляция через PostgreSQL schemas
- Роли автоматически создаются при создании нового тенанта

Компоненты:
- user_roles/models.py: модели Role и UserRole
- user_roles/services.py: RoleService для управления ролями
- user_roles/decorators.py: @role_required, @owner_required
- user_roles/mixins.py: RoleBasedAdminMixin, OwnerOnlyAdminMixin
- user_roles/admin.py: админка для управления ролями
- user_roles/management/commands/init_roles.py: команда для инициализации

Изменения:
- accounts/models.py: добавлены helper методы (is_owner, has_role, etc)
- settings.py: добавлен user_roles в TENANT_APPS
- tenants/admin.py: автосоздание ролей при создании тенанта

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-01 18:06:47 +03:00
parent eef2cb820f
commit f4e7ad0aac
17 changed files with 471 additions and 0 deletions

View File

@@ -79,3 +79,33 @@ class CustomUser(AbstractUser):
self.is_email_confirmed = True
self.email_confirmed_at = timezone.now()
self.save()
def get_tenant_role(self):
"""Получить роль пользователя в текущем тенанте"""
from user_roles.services import RoleService
return RoleService.get_user_role(self)
def has_role(self, *role_codes):
"""Проверить, имеет ли пользователь одну из указанных ролей"""
from user_roles.services import RoleService
return RoleService.user_has_role(self, *role_codes)
@property
def is_owner(self):
"""Является ли пользователь владельцем"""
return self.has_role('owner')
@property
def is_manager(self):
"""Является ли пользователь менеджером"""
return self.has_role('manager')
@property
def is_florist(self):
"""Является ли пользователь флористом"""
return self.has_role('florist')
@property
def is_courier(self):
"""Является ли пользователь курьером"""
return self.has_role('courier')

View File

@@ -70,6 +70,7 @@ TENANT_APPS = [
'simple_history', # История изменений для каждого тенанта
'nested_admin',
'django_filters', # Фильтрация данных
'user_roles', # Роли пользователей
'customers', # Клиенты магазина
'products', # Товары и категории
'orders', # Заказы

View File

@@ -310,6 +310,17 @@ class TenantRegistrationAdmin(admin.ModelAdmin):
logger.error(f"Ошибка при создании статусов заказов: {e}", exc_info=True)
# Не прерываем процесс, т.к. это не критично
# Создаем системные роли пользователей
logger.info(f"Создание системных ролей для тенанта: {client.id}")
from user_roles.services import RoleService
try:
RoleService.create_default_roles()
logger.info("Системные роли успешно созданы")
except Exception as e:
logger.error(f"Ошибка при создании ролей: {e}", exc_info=True)
# Не прерываем процесс, т.к. это не критично
# Создаем системные способы оплаты
logger.info(f"Создание системных способов оплаты для тенанта: {client.id}")
from django.core.management import call_command

View File

View File

@@ -0,0 +1,39 @@
from django.contrib import admin
from user_roles.models import Role, UserRole
from user_roles.mixins import OwnerOnlyAdminMixin
@admin.register(Role)
class RoleAdmin(admin.ModelAdmin):
list_display = ['code', 'name', 'is_system']
list_filter = ['is_system']
search_fields = ['code', 'name']
readonly_fields = ['created_at']
def has_delete_permission(self, request, obj=None):
"""Запрет удаления системных ролей"""
if obj and obj.is_system:
return False
return super().has_delete_permission(request, obj)
@admin.register(UserRole)
class UserRoleAdmin(OwnerOnlyAdminMixin, admin.ModelAdmin):
"""
Админка ролей пользователей.
Доступна только владельцу.
ВАЖНО: UserRole изолирован по тенантам автоматически через django-tenants,
поэтому владелец видит только пользователей своего магазина!
"""
list_display = ['user', 'role', 'is_active', 'created_at']
list_filter = ['role', 'is_active']
search_fields = ['user__email', 'user__name']
readonly_fields = ['created_at', 'created_by']
autocomplete_fields = ['user']
def save_model(self, request, obj, form, change):
"""Автоматически устанавливаем created_by"""
if not change:
obj.created_by = request.user
super().save_model(request, obj, form, change)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class UserRolesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'user_roles'

View File

@@ -0,0 +1,40 @@
from functools import wraps
from django.http import HttpResponseForbidden
from django.shortcuts import redirect
from django.contrib import messages
from user_roles.services import RoleService
def role_required(*role_codes):
"""
Декоратор для проверки роли пользователя.
Использование:
@role_required('owner', 'manager')
def my_view(request):
...
"""
def decorator(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
if not request.user.is_authenticated:
return redirect('login')
if RoleService.user_has_role(request.user, *role_codes):
return view_func(request, *args, **kwargs)
messages.error(request, 'У вас нет прав для выполнения этого действия.')
return HttpResponseForbidden('Access denied')
return wrapper
return decorator
def owner_required(view_func):
"""Декоратор для проверки роли Владелец"""
return role_required('owner')(view_func)
def manager_or_owner_required(view_func):
"""Декоратор для проверки роли Менеджер или Владелец"""
return role_required('owner', 'manager')(view_func)

View File

@@ -0,0 +1,15 @@
from django.core.management.base import BaseCommand
from user_roles.services import RoleService
class Command(BaseCommand):
help = 'Создает дефолтные роли для текущего тенанта'
def handle(self, *args, **options):
self.stdout.write('Создание дефолтных ролей...')
RoleService.create_default_roles()
self.stdout.write(
self.style.SUCCESS('✓ Роли успешно созданы')
)

View File

@@ -0,0 +1,48 @@
# Generated by Django 5.0.10 on 2025-12-01 15:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Role',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(choices=[('owner', 'Владелец'), ('manager', 'Менеджер'), ('florist', 'Флорист'), ('courier', 'Курьер')], max_length=20, unique=True, verbose_name='Код роли')),
('name', models.CharField(max_length=100, verbose_name='Название')),
('description', models.TextField(blank=True, verbose_name='Описание')),
('is_system', models.BooleanField(default=True, help_text='Системные роли нельзя удалить', verbose_name='Системная роль')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Роль',
'verbose_name_plural': 'Роли',
'ordering': ['code'],
},
),
migrations.CreateModel(
name='UserRole',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_user_roles', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
('role', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='users', to='user_roles.role', verbose_name='Роль')),
('user', models.OneToOneField(help_text='Пользователь из public schema (SHARED_APPS)', on_delete=django.db.models.deletion.CASCADE, related_name='tenant_role', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Роль пользователя',
'verbose_name_plural': 'Роли пользователей',
},
),
]

View File

@@ -0,0 +1,74 @@
from django.contrib import admin
from django.core.exceptions import PermissionDenied
from user_roles.services import RoleService
class RoleBasedAdminMixin:
"""
Миксин для ModelAdmin с проверкой ролей.
Использование:
class MyAdmin(RoleBasedAdminMixin, admin.ModelAdmin):
required_roles = ['owner', 'manager']
"""
required_roles = [] # Роли, которые имеют доступ
def has_module_permission(self, request):
"""Проверка доступа к модулю"""
if not super().has_module_permission(request):
return False
if not self.required_roles:
return True # Нет ограничений
return RoleService.user_has_role(request.user, *self.required_roles)
def has_view_permission(self, request, obj=None):
"""Проверка доступа на просмотр"""
if not super().has_view_permission(request, obj):
return False
if not self.required_roles:
return True
return RoleService.user_has_role(request.user, *self.required_roles)
def has_add_permission(self, request):
"""Проверка доступа на добавление"""
if not super().has_add_permission(request):
return False
if not self.required_roles:
return True
return RoleService.user_has_role(request.user, *self.required_roles)
def has_change_permission(self, request, obj=None):
"""Проверка доступа на изменение"""
if not super().has_change_permission(request, obj):
return False
if not self.required_roles:
return True
return RoleService.user_has_role(request.user, *self.required_roles)
def has_delete_permission(self, request, obj=None):
"""Проверка доступа на удаление"""
if not super().has_delete_permission(request, obj):
return False
if not self.required_roles:
return True
return RoleService.user_has_role(request.user, *self.required_roles)
class OwnerOnlyAdminMixin(RoleBasedAdminMixin):
"""Миксин для админки, доступной только владельцу"""
required_roles = ['owner']
class ManagerOwnerAdminMixin(RoleBasedAdminMixin):
"""Миксин для админки, доступной менеджеру и владельцу"""
required_roles = ['owner', 'manager']

View File

@@ -0,0 +1,101 @@
from django.db import models
from django.conf import settings
from django.utils import timezone
class Role(models.Model):
"""
Роль пользователя.
ВАЖНО: Модель находится в TENANT_APPS, поэтому автоматически изолируется
по тенантам через django-tenants. Каждый тенант имеет свой набор ролей
в своей PostgreSQL schema.
Не нужно явно связывать с тенантом через FK - изоляция происходит автоматически!
"""
OWNER = 'owner'
MANAGER = 'manager'
FLORIST = 'florist'
COURIER = 'courier'
ROLE_CHOICES = [
(OWNER, 'Владелец'),
(MANAGER, 'Менеджер'),
(FLORIST, 'Флорист'),
(COURIER, 'Курьер'),
]
code = models.CharField(
max_length=20,
choices=ROLE_CHOICES,
unique=True,
verbose_name="Код роли"
)
name = models.CharField(
max_length=100,
verbose_name="Название"
)
description = models.TextField(
blank=True,
verbose_name="Описание"
)
is_system = models.BooleanField(
default=True,
verbose_name="Системная роль",
help_text="Системные роли нельзя удалить"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Роль"
verbose_name_plural = "Роли"
ordering = ['code']
def __str__(self):
return self.name
class UserRole(models.Model):
"""
Роль пользователя в текущем тенанте.
ВАЖНО: Эта модель НЕ связывает пользователя с тенантом!
Связь с тенантом обеспечивается автоматически через django-tenants
(модель в TENANT_APPS = находится в schema тенанта).
UserRole просто говорит: "этот пользователь имеет эту роль" (в рамках текущего тенанта).
Один пользователь = одна роль в рамках одного тенанта.
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='tenant_role',
verbose_name="Пользователь",
help_text="Пользователь из public schema (SHARED_APPS)"
)
role = models.ForeignKey(
Role,
on_delete=models.PROTECT,
related_name='users',
verbose_name="Роль"
)
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='created_user_roles',
verbose_name="Создал"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активен"
)
class Meta:
verbose_name = "Роль пользователя"
verbose_name_plural = "Роли пользователей"
def __str__(self):
return f"{self.user.email} - {self.role.name}"

View File

@@ -0,0 +1,100 @@
from django.db import transaction
from user_roles.models import Role, UserRole
from django.contrib.auth import get_user_model
User = get_user_model()
class RoleService:
"""Сервис для управления ролями"""
@staticmethod
def create_default_roles():
"""
Создает системные роли для тенанта.
Вызывается при создании тенанта (автоматически через signals или admin).
ВАЖНО: Эта функция вызывается в контексте schema_context(tenant.schema_name),
поэтому роли создаются в schema конкретного тенанта автоматически!
"""
default_roles = [
{
'code': Role.OWNER,
'name': 'Владелец',
'description': 'Полный доступ ко всем функциям системы. Управление пользователями и настройками.',
'is_system': True,
},
{
'code': Role.MANAGER,
'name': 'Менеджер',
'description': 'Управление заказами, клиентами, товарами и складом. Без доступа к настройкам.',
'is_system': True,
},
{
'code': Role.FLORIST,
'name': 'Флорист',
'description': 'Работа с заказами и складскими операциями для заказов.',
'is_system': True,
},
{
'code': Role.COURIER,
'name': 'Курьер',
'description': 'Доставка заказов (права будут определены позже).',
'is_system': True,
},
]
for role_data in default_roles:
Role.objects.get_or_create(
code=role_data['code'],
defaults=role_data
)
@staticmethod
def get_role_by_code(code):
"""Получить роль по коду"""
try:
return Role.objects.get(code=code, is_system=True)
except Role.DoesNotExist:
return None
@staticmethod
@transaction.atomic
def assign_role_to_user(user, role_code, created_by=None):
"""
Назначить роль пользователю в текущем тенанте.
Если у пользователя уже есть роль - обновляет её.
ВАЖНО: Вызывается в контексте текущего тенанта (через middleware),
поэтому UserRole создается в schema текущего тенанта автоматически!
"""
role = RoleService.get_role_by_code(role_code)
if not role:
raise ValueError(f"Роль с кодом '{role_code}' не найдена")
user_role, created = UserRole.objects.update_or_create(
user=user,
defaults={
'role': role,
'created_by': created_by,
'is_active': True,
}
)
return user_role
@staticmethod
def get_user_role(user):
"""Получить роль пользователя в текущем тенанте"""
try:
user_role = UserRole.objects.get(user=user, is_active=True)
return user_role.role
except UserRole.DoesNotExist:
return None
@staticmethod
def user_has_role(user, *role_codes):
"""Проверить, имеет ли пользователь одну из указанных ролей"""
user_role = RoleService.get_user_role(user)
if not user_role:
return False
return user_role.code in role_codes

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.