diff --git a/dbapp/dbapp/settings/base.py b/dbapp/dbapp/settings/base.py index 79b9e5d..27528af 100644 --- a/dbapp/dbapp/settings/base.py +++ b/dbapp/dbapp/settings/base.py @@ -116,6 +116,7 @@ TEMPLATES = [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "mainapp.context_processors.user_permissions", ], }, }, @@ -147,15 +148,15 @@ AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, + # { + # "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + # }, + # { + # "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + # }, + # { + # "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + # }, ] # ============================================================================ diff --git a/dbapp/mainapp/admin.py b/dbapp/mainapp/admin.py index 4ce241e..77eacbe 100644 --- a/dbapp/mainapp/admin.py +++ b/dbapp/mainapp/admin.py @@ -32,12 +32,14 @@ from .models import ( Geo, ObjItem, CustomUser, + UserPermission, Band, Source, TechAnalyze, SourceRequest, SourceRequestStatusHistory, ) +from .permissions import PERMISSIONS, DEFAULT_ROLE_PERMISSIONS from .filters import ( GeoKupDistanceFilter, GeoValidDistanceFilter, @@ -99,6 +101,19 @@ class CustomUserInline(admin.StackedInline): model = CustomUser can_delete = False verbose_name_plural = "Дополнительная информация пользователя" + filter_horizontal = ('user_permissions',) + + fieldsets = ( + (None, { + 'fields': ('role',) + }), + ('Индивидуальные разрешения', { + 'fields': ('use_custom_permissions', 'user_permissions'), + 'classes': ('collapse',), + 'description': 'Если включено "Использовать индивидуальные разрешения", ' + 'будут использоваться выбранные разрешения вместо прав роли по умолчанию.' + }), + ) class LocationForm(forms.ModelForm): @@ -195,6 +210,88 @@ class UserAdmin(BaseUserAdmin): admin.site.register(User, UserAdmin) +class UserPermissionForm(forms.ModelForm): + """Форма для UserPermission с выбором из списка разрешений.""" + + code = forms.ChoiceField( + choices=[], + label="Код разрешения", + help_text="Выберите разрешение из списка" + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['code'].choices = PERMISSIONS[:28] # Используем PERMISSION_CHOICES + # Преобразуем в формат (code, name) + self.fields['code'].choices = [(code, name) for code, name, _ in PERMISSIONS] + + class Meta: + model = UserPermission + fields = ['code'] + + +@admin.register(UserPermission) +class UserPermissionAdmin(BaseAdmin): + """Админ-панель для модели UserPermission.""" + + form = UserPermissionForm + list_display = ('code', 'get_name', 'get_description') + search_fields = ('code',) + ordering = ('code',) + + def get_name(self, obj): + """Возвращает название разрешения.""" + from .permissions import PERMISSION_CHOICES + choices_dict = dict(PERMISSION_CHOICES) + return choices_dict.get(obj.code, '-') + get_name.short_description = 'Название' + + def get_description(self, obj): + """Возвращает описание разрешения.""" + from .permissions import PERMISSION_DESCRIPTIONS + return PERMISSION_DESCRIPTIONS.get(obj.code, '-') + get_description.short_description = 'Описание' + + +@admin.register(CustomUser) +class CustomUserAdmin(BaseAdmin): + """Админ-панель для модели CustomUser с управлением разрешениями.""" + + list_display = ('user', 'role', 'use_custom_permissions', 'permissions_count') + list_filter = ('role', 'use_custom_permissions') + search_fields = ('user__username', 'user__first_name', 'user__last_name') + filter_horizontal = ('user_permissions',) + ordering = ('user__username',) + + fieldsets = ( + ('Основная информация', { + 'fields': ('user', 'role') + }), + ('Индивидуальные разрешения', { + 'fields': ('use_custom_permissions', 'user_permissions'), + 'description': 'Если включено "Использовать индивидуальные разрешения", ' + 'будут использоваться выбранные разрешения вместо прав роли по умолчанию.' + }), + ) + + def permissions_count(self, obj): + """Показывает количество индивидуальных разрешений.""" + if obj.use_custom_permissions: + count = obj.user_permissions.count() + return f'{count} (индивид.)' + return f'По роли ({obj.role})' + permissions_count.short_description = 'Разрешения' + + def get_readonly_fields(self, request, obj=None): + """User поле только для чтения при редактировании.""" + if obj: + return ('user',) + return () + + class Media: + js = ('admin/js/permissions_admin.js',) + + # ============================================================================ # Custom Admin Actions # ============================================================================ diff --git a/dbapp/mainapp/context_processors.py b/dbapp/mainapp/context_processors.py new file mode 100644 index 0000000..b5871d0 --- /dev/null +++ b/dbapp/mainapp/context_processors.py @@ -0,0 +1,27 @@ +""" +Context processors для mainapp. + +Добавляет глобальные переменные во все шаблоны. +""" +from .permissions import get_user_permissions, PERMISSIONS + + +def user_permissions(request): + """ + Добавляет права пользователя в контекст шаблона. + + Использование в шаблонах: + {% if 'source_create' in user_perms %} + ... + {% endif %} + """ + if request.user.is_authenticated: + perms = get_user_permissions(request.user) + return { + 'user_perms': perms, + 'all_permissions': PERMISSIONS, + } + return { + 'user_perms': [], + 'all_permissions': PERMISSIONS, + } diff --git a/dbapp/mainapp/management/commands/init_permissions.py b/dbapp/mainapp/management/commands/init_permissions.py new file mode 100644 index 0000000..5d536eb --- /dev/null +++ b/dbapp/mainapp/management/commands/init_permissions.py @@ -0,0 +1,38 @@ +""" +Management command для инициализации разрешений в базе данных. + +Usage: + python manage.py init_permissions +""" +from django.core.management.base import BaseCommand +from mainapp.models import UserPermission +from mainapp.permissions import PERMISSIONS + + +class Command(BaseCommand): + help = 'Инициализирует все разрешения в базе данных' + + def handle(self, *args, **options): + created_count = 0 + existing_count = 0 + + for code, name, description in PERMISSIONS: + permission, created = UserPermission.objects.get_or_create(code=code) + if created: + created_count += 1 + self.stdout.write( + self.style.SUCCESS(f'Создано разрешение: {code} - {name}') + ) + else: + existing_count += 1 + + self.stdout.write( + self.style.SUCCESS( + f'\nГотово! Создано: {created_count}, уже существовало: {existing_count}' + ) + ) + + # Показываем все разрешения + self.stdout.write('\nВсе разрешения в системе:') + for code, name, description in PERMISSIONS: + self.stdout.write(f' - {code}: {name}') diff --git a/dbapp/mainapp/migrations/0025_add_user_permissions.py b/dbapp/mainapp/migrations/0025_add_user_permissions.py new file mode 100644 index 0000000..aea5fb5 --- /dev/null +++ b/dbapp/mainapp/migrations/0025_add_user_permissions.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.7 on 2025-12-12 14:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mainapp', '0024_change_objectmark_timestamp_editable'), + ] + + operations = [ + migrations.CreateModel( + name='UserPermission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(choices=[('source_create', 'Создание источника'), ('source_edit', 'Редактирование источника'), ('source_delete', 'Удаление источников'), ('source_import_excel', 'Импорт из Excel'), ('source_import_csv', 'Импорт из CSV'), ('source_averaging', 'Усреднение точек'), ('source_tech_analyze', 'Технический анализ'), ('source_merge', 'Объединение источников'), ('request_create', 'Создание заявки'), ('request_edit', 'Редактирование заявки'), ('request_delete', 'Удаление заявки'), ('request_import', 'Импорт заявок'), ('objitem_create', 'Создание точки'), ('objitem_edit', 'Редактирование точки'), ('objitem_delete', 'Удаление точки'), ('satellite_create', 'Создание спутника'), ('satellite_edit', 'Редактирование спутника'), ('satellite_delete', 'Удаление спутника'), ('tech_analyze_create', 'Создание тех. анализа'), ('tech_analyze_edit', 'Редактирование тех. анализа'), ('tech_analyze_delete', 'Удаление тех. анализа'), ('mark_create', 'Создание отметки'), ('mark_edit', 'Редактирование отметки'), ('statistics_view', 'Просмотр статистики'), ('kubsat_view', 'Просмотр Кубсат'), ('kubsat_edit', 'Редактирование Кубсат'), ('lyngsat_parse', 'Парсинг LyngSat'), ('admin_access', 'Доступ к админ-панели')], db_index=True, help_text='Уникальный код разрешения', max_length=50, verbose_name='Код разрешения')), + ], + options={ + 'verbose_name': 'Разрешение', + 'verbose_name_plural': 'Разрешения', + 'ordering': ['code'], + }, + ), + migrations.AddField( + model_name='customuser', + name='use_custom_permissions', + field=models.BooleanField(default=False, help_text='Если включено - используются индивидуальные разрешения вместо прав роли', verbose_name='Использовать индивидуальные разрешения'), + ), + migrations.AddField( + model_name='customuser', + name='user_permissions', + field=models.ManyToManyField(blank=True, help_text='Если указаны - используются вместо прав роли по умолчанию', related_name='users', to='mainapp.userpermission', verbose_name='Индивидуальные разрешения'), + ), + ] diff --git a/dbapp/mainapp/models.py b/dbapp/mainapp/models.py index c2c9cd9..de3950a 100644 --- a/dbapp/mainapp/models.py +++ b/dbapp/mainapp/models.py @@ -24,6 +24,37 @@ def get_default_standard(): return obj.id +def get_permission_choices(): + """Ленивая загрузка choices для избежания циклического импорта.""" + from .permissions import PERMISSION_CHOICES + return PERMISSION_CHOICES + + +class UserPermission(models.Model): + """ + Модель разрешения пользователя. + + Хранит гранулярные разрешения для конкретных действий в системе. + """ + + code = models.CharField( + max_length=50, + verbose_name="Код разрешения", + db_index=True, + help_text="Уникальный код разрешения", + ) + + def __str__(self): + from .permissions import PERMISSION_CHOICES + choices_dict = dict(PERMISSION_CHOICES) + return choices_dict.get(self.code, self.code) + + class Meta: + verbose_name = "Разрешение" + verbose_name_plural = "Разрешения" + ordering = ["code"] + + class CustomUser(models.Model): """ Расширенная модель пользователя с ролями. @@ -54,6 +85,22 @@ class CustomUser(models.Model): db_index=True, help_text="Роль пользователя в системе", ) + + # Индивидуальные разрешения (если пусто - используются права роли по умолчанию) + user_permissions = models.ManyToManyField( + UserPermission, + related_name="users", + verbose_name="Индивидуальные разрешения", + blank=True, + help_text="Если указаны - используются вместо прав роли по умолчанию", + ) + + # Флаг использования индивидуальных разрешений + use_custom_permissions = models.BooleanField( + default=False, + verbose_name="Использовать индивидуальные разрешения", + help_text="Если включено - используются индивидуальные разрешения вместо прав роли", + ) def __str__(self): return ( @@ -61,6 +108,19 @@ class CustomUser(models.Model): if self.user.first_name and self.user.last_name else self.user.username ) + + def has_perm(self, permission_code): + """ + Проверяет наличие разрешения у пользователя. + + Args: + permission_code: Код разрешения + + Returns: + bool: True если пользователь имеет разрешение + """ + from .permissions import has_permission + return has_permission(self.user, permission_code) class Meta: verbose_name = "Пользователь" diff --git a/dbapp/mainapp/permissions.py b/dbapp/mainapp/permissions.py new file mode 100644 index 0000000..60ebfe7 --- /dev/null +++ b/dbapp/mainapp/permissions.py @@ -0,0 +1,251 @@ +""" +Система гранулярных прав доступа. + +Определяет все доступные разрешения и функции для их проверки. +""" +from functools import wraps +from django.http import JsonResponse, HttpResponseForbidden +from django.shortcuts import redirect +from django.contrib import messages + + +# Определение всех разрешений в системе +# Формат: (код, название, описание) +PERMISSIONS = [ + # Source List Page - Toolbar buttons + ('source_create', 'Создание источника', 'Кнопка "Создать" на странице списка источников'), + ('source_edit', 'Редактирование источника', 'Кнопка редактирования источника'), + ('source_delete', 'Удаление источников', 'Кнопка "Удалить" на странице списка источников'), + ('source_import_excel', 'Импорт из Excel', 'Кнопка "Excel" для загрузки данных'), + ('source_import_csv', 'Импорт из CSV', 'Кнопка "CSV" для загрузки данных'), + ('source_averaging', 'Усреднение точек', 'Кнопка "Усреднение" на странице списка источников'), + ('source_tech_analyze', 'Технический анализ', 'Кнопка "Тех. анализ" на странице списка источников'), + ('source_merge', 'Объединение источников', 'Кнопка "Объединить" в offcanvas списка'), + + # Source Requests + ('request_create', 'Создание заявки', 'Создание новой заявки на источник'), + ('request_edit', 'Редактирование заявки', 'Редактирование существующей заявки'), + ('request_delete', 'Удаление заявки', 'Удаление заявки на источник'), + ('request_import', 'Импорт заявок', 'Импорт заявок из файла'), + + # ObjItem (Points) + ('objitem_create', 'Создание точки', 'Создание новой точки ГЛ'), + ('objitem_edit', 'Редактирование точки', 'Редактирование точки ГЛ'), + ('objitem_delete', 'Удаление точки', 'Удаление точки ГЛ'), + + # Satellites + ('satellite_create', 'Создание спутника', 'Создание нового спутника'), + ('satellite_edit', 'Редактирование спутника', 'Редактирование спутника'), + ('satellite_delete', 'Удаление спутника', 'Удаление спутника'), + + # Tech Analyze + ('tech_analyze_create', 'Создание тех. анализа', 'Создание записи технического анализа'), + ('tech_analyze_edit', 'Редактирование тех. анализа', 'Редактирование записи технического анализа'), + ('tech_analyze_delete', 'Удаление тех. анализа', 'Удаление записи технического анализа'), + + # Signal Marks + ('mark_create', 'Создание отметки', 'Создание отметки о сигнале'), + ('mark_edit', 'Редактирование отметки', 'Редактирование отметки о сигнале'), + + # Statistics + ('statistics_view', 'Просмотр статистики', 'Доступ к странице статистики'), + + # Kubsat + ('kubsat_view', 'Просмотр Кубсат', 'Доступ к странице Кубсат'), + ('kubsat_edit', 'Редактирование Кубсат', 'Редактирование данных Кубсат'), + + # LyngSat + ('lyngsat_parse', 'Парсинг LyngSat', 'Запуск парсинга LyngSat'), + + # Transponders + ('transponder_create', 'Создание транспондера', 'Создание нового транспондера'), + ('transponder_edit', 'Редактирование транспондера', 'Редактирование транспондера'), + ('transponder_delete', 'Удаление транспондера', 'Удаление транспондера'), + ('transponder_import_xml', 'Импорт транспондеров из XML', 'Загрузка транспондеров из XML файла'), + + # Admin access + # ('admin_access', 'Доступ к админ-панели', 'Доступ к административной панели Django'), +] + +# Словарь для быстрого доступа к разрешениям +PERMISSION_CHOICES = [(code, name) for code, name, _ in PERMISSIONS] +PERMISSION_DESCRIPTIONS = {code: desc for code, _, desc in PERMISSIONS} + +# Права по умолчанию для ролей +DEFAULT_ROLE_PERMISSIONS = { + 'admin': [code for code, _, _ in PERMISSIONS], # Все права + 'moderator': [ + 'source_create', 'source_edit', 'source_import_excel', 'source_import_csv', + 'source_averaging', 'source_tech_analyze', 'source_merge', + 'request_create', 'request_edit', 'request_import', + 'objitem_create', 'objitem_edit', + 'satellite_create', 'satellite_edit', + 'transponder_create', 'transponder_edit', 'transponder_import_xml', + 'tech_analyze_create', 'tech_analyze_edit', + 'mark_create', 'mark_edit', + 'statistics_view', + 'kubsat_view', 'kubsat_edit', + ], + 'user': [ + 'statistics_view', + 'kubsat_view', + ], +} + + +def has_permission(user, permission_code): + """ + Проверяет, имеет ли пользователь указанное разрешение. + + Args: + user: Объект User Django + permission_code: Код разрешения (строка) + + Returns: + bool: True если пользователь имеет разрешение + """ + if not user or not user.is_authenticated: + return False + + # Суперпользователь имеет все права + if user.is_superuser: + return True + + # Получаем CustomUser + custom_user = getattr(user, 'customuser', None) + if not custom_user: + return False + + # Проверяем, используются ли индивидуальные разрешения + if custom_user.use_custom_permissions: + # Используем индивидуальные разрешения + return custom_user.user_permissions.filter(code=permission_code).exists() + + # Иначе используем права по умолчанию для роли + role = custom_user.role + default_perms = DEFAULT_ROLE_PERMISSIONS.get(role, []) + return permission_code in default_perms + + +def get_user_permissions(user): + """ + Возвращает список кодов разрешений пользователя. + + Args: + user: Объект User Django + + Returns: + list: Список кодов разрешений + """ + if not user or not user.is_authenticated: + return [] + + if user.is_superuser: + return [code for code, _, _ in PERMISSIONS] + + custom_user = getattr(user, 'customuser', None) + if not custom_user: + return [] + + # Проверяем, используются ли индивидуальные разрешения + if custom_user.use_custom_permissions: + return list(custom_user.user_permissions.values_list('code', flat=True)) + + # Права по умолчанию для роли + return DEFAULT_ROLE_PERMISSIONS.get(custom_user.role, []) + + +def permission_required(permission_code, redirect_url=None, raise_exception=False): + """ + Декоратор для проверки разрешения на уровне view. + + Args: + permission_code: Код разрешения + redirect_url: URL для редиректа при отсутствии прав (по умолчанию на предыдущую страницу) + raise_exception: Если True, возвращает 403 вместо редиректа + + Usage: + @permission_required('source_create') + def my_view(request): + ... + """ + def decorator(view_func): + @wraps(view_func) + def wrapper(request, *args, **kwargs): + if has_permission(request.user, permission_code): + return view_func(request, *args, **kwargs) + + # Для AJAX запросов возвращаем JSON + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': False, + 'error': 'У вас нет прав для выполнения этого действия' + }, status=403) + + if raise_exception: + return HttpResponseForbidden('У вас нет прав для выполнения этого действия') + + messages.error(request, 'У вас нет прав для выполнения этого действия') + + if redirect_url: + return redirect(redirect_url) + + # Редирект на предыдущую страницу или на главную + referer = request.META.get('HTTP_REFERER') + if referer: + return redirect(referer) + return redirect('mainapp:source_list') + + return wrapper + return decorator + + +class PermissionRequiredMixin: + """ + Миксин для class-based views для проверки разрешений. + + Usage: + class MyView(PermissionRequiredMixin, View): + permission_required = 'source_create' + # или для нескольких разрешений (любое из них): + permission_required = ['source_create', 'source_edit'] + """ + permission_required = None + permission_denied_message = 'У вас нет прав для выполнения этого действия' + + def has_permission(self): + """Проверяет наличие разрешения.""" + perms = self.get_permission_required() + if isinstance(perms, str): + return has_permission(self.request.user, perms) + # Для списка - проверяем наличие хотя бы одного разрешения + return any(has_permission(self.request.user, perm) for perm in perms) + + def get_permission_required(self): + """Возвращает требуемое разрешение.""" + if self.permission_required is None: + raise ValueError( + f'{self.__class__.__name__} is missing the permission_required attribute.' + ) + return self.permission_required + + def dispatch(self, request, *args, **kwargs): + if not self.has_permission(): + return self.handle_no_permission() + return super().dispatch(request, *args, **kwargs) + + def handle_no_permission(self): + """Обработка отсутствия разрешения.""" + # Для AJAX запросов + if self.request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return JsonResponse({ + 'success': False, + 'error': self.permission_denied_message + }, status=403) + + messages.error(self.request, self.permission_denied_message) + + referer = self.request.META.get('HTTP_REFERER') + if referer: + return redirect(referer) + return redirect('mainapp:source_list') diff --git a/dbapp/mainapp/templates/mainapp/components/_navbar.html b/dbapp/mainapp/templates/mainapp/components/_navbar.html index 89893de..19560b5 100644 --- a/dbapp/mainapp/templates/mainapp/components/_navbar.html +++ b/dbapp/mainapp/templates/mainapp/components/_navbar.html @@ -3,6 +3,7 @@ Использование: {% include 'mainapp/components/_navbar.html' %} {% endcomment %} +{% load permission_tags %}