7074244479
Кнопка «Матрица доступа» на списке групп (unfold actions_list) открывает сетку: строки — разделы портала, колонки — все группы, галочки просмотра. Сохранение через apply_full_access_matrix (CUD не трогает — они на карточке группы). Логика переиспользована из user_groups_portal. Смоук-тесты рендера и сохранения. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
361 lines
14 KiB
Python
361 lines
14 KiB
Python
"""Портал: группы пользователей и сводная таблица доступа."""
|
||
from __future__ import annotations
|
||
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.contrib.auth.models import Group, Permission
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from django.db import transaction
|
||
from django.shortcuts import get_object_or_404, redirect
|
||
from django.views.decorators.http import require_POST
|
||
|
||
from apps.employees.models import Employee, EmployeePermissionOverride, GroupSectionAccess
|
||
from core.models import PortalAccess
|
||
from core.portal_access import (
|
||
matrix_section_keys,
|
||
matrix_section_label,
|
||
section_required,
|
||
)
|
||
from core.utils.htmx import render_page
|
||
|
||
|
||
def _portal_access_perms_by_key() -> dict[str, Permission]:
|
||
ct = ContentType.objects.get_for_model(PortalAccess, for_concrete_model=False)
|
||
perms = Permission.objects.filter(content_type=ct, codename__startswith='access_')
|
||
return {p.codename[len('access_'):]: p for p in perms}
|
||
|
||
|
||
def _view_all_reports_perm() -> Permission | None:
|
||
return Permission.objects.filter(
|
||
content_type__app_label='reports',
|
||
codename='view_all_departments',
|
||
).first()
|
||
|
||
|
||
def group_can_view_all_reports(group: Group) -> bool:
|
||
return group.permissions.filter(
|
||
content_type__app_label='reports',
|
||
codename='view_all_departments',
|
||
).exists()
|
||
|
||
|
||
def _sync_view_all_reports(group: Group, enabled: bool) -> None:
|
||
perm = _view_all_reports_perm()
|
||
if perm is None:
|
||
return
|
||
if enabled:
|
||
group.permissions.add(perm)
|
||
else:
|
||
group.permissions.remove(perm)
|
||
|
||
|
||
def employee_has_individual_view_all_reports(employee) -> bool:
|
||
perm = _view_all_reports_perm()
|
||
if perm is None:
|
||
return False
|
||
if employee.user_permissions.filter(pk=perm.pk).exists():
|
||
return True
|
||
return EmployeePermissionOverride.objects.filter(
|
||
employee=employee,
|
||
permission=perm,
|
||
override_type='grant',
|
||
is_active=True,
|
||
).exists()
|
||
|
||
|
||
def grant_individual_view_all_reports(employee) -> None:
|
||
perm = _view_all_reports_perm()
|
||
if perm is None:
|
||
raise ValueError('Право «Видеть отчёты всех отделов» ещё не создано. Примените миграции.')
|
||
EmployeePermissionOverride.objects.filter(
|
||
employee=employee,
|
||
permission=perm,
|
||
override_type='revoke',
|
||
).update(is_active=False)
|
||
row = EmployeePermissionOverride.objects.filter(
|
||
employee=employee,
|
||
permission=perm,
|
||
override_type='grant',
|
||
).first()
|
||
if row:
|
||
row.is_active = True
|
||
if not row.comment:
|
||
row.comment = 'Видеть отчёты всех отделов'
|
||
row.save(update_fields=['is_active', 'comment', 'updated_at'])
|
||
else:
|
||
EmployeePermissionOverride.objects.create(
|
||
employee=employee,
|
||
permission=perm,
|
||
override_type='grant',
|
||
comment='Видеть отчёты всех отделов',
|
||
)
|
||
|
||
|
||
def revoke_individual_view_all_reports(employee) -> None:
|
||
perm = _view_all_reports_perm()
|
||
if perm is None:
|
||
return
|
||
EmployeePermissionOverride.objects.filter(
|
||
employee=employee,
|
||
permission=perm,
|
||
override_type='grant',
|
||
).update(is_active=False)
|
||
employee.user_permissions.remove(perm)
|
||
|
||
|
||
def individual_view_all_report_employees():
|
||
perm = _view_all_reports_perm()
|
||
if perm is None:
|
||
return Employee.objects.none()
|
||
from django.db.models import Q
|
||
|
||
return (
|
||
Employee.objects.filter(is_active=True)
|
||
.filter(
|
||
Q(user_permissions=perm)
|
||
| Q(
|
||
permission_overrides__permission=perm,
|
||
permission_overrides__override_type='grant',
|
||
permission_overrides__is_active=True,
|
||
)
|
||
)
|
||
.distinct()
|
||
.order_by('last_name', 'first_name', 'username')
|
||
)
|
||
|
||
|
||
def _group_view_keys(group: Group) -> set[str]:
|
||
ct = ContentType.objects.get_for_model(PortalAccess, for_concrete_model=False)
|
||
codenames = group.permissions.filter(
|
||
content_type=ct, codename__startswith='access_'
|
||
).values_list('codename', flat=True)
|
||
return {c[len('access_'):] for c in codenames}
|
||
|
||
|
||
def _matrix_rows(group: Group) -> list[dict]:
|
||
view_keys = _group_view_keys(group)
|
||
cud = {
|
||
row.section_key: row
|
||
for row in GroupSectionAccess.objects.filter(group=group)
|
||
}
|
||
rows = []
|
||
for key in matrix_section_keys():
|
||
access = cud.get(key)
|
||
can_view = key in view_keys
|
||
rows.append({
|
||
'key': key,
|
||
'label': matrix_section_label(key),
|
||
'can_view': can_view,
|
||
'can_create': bool(access.can_create) if access and can_view else (can_view),
|
||
'can_update': bool(access.can_update) if access and can_view else (can_view),
|
||
'can_delete': bool(access.can_delete) if access and can_view else (can_view),
|
||
})
|
||
return rows
|
||
|
||
|
||
def apply_group_access_matrix(group: Group, posted: dict) -> None:
|
||
"""Синхронизировать access_* и GroupSectionAccess из POST-данных матрицы."""
|
||
perms_by_key = _portal_access_perms_by_key()
|
||
keys = matrix_section_keys()
|
||
|
||
with transaction.atomic():
|
||
for key in keys:
|
||
perm = perms_by_key.get(key)
|
||
if perm is None:
|
||
continue
|
||
view_on = posted.get(f'view_{key}') in ('1', 'on', 'yes', 'view')
|
||
if view_on:
|
||
group.permissions.add(perm)
|
||
GroupSectionAccess.objects.update_or_create(
|
||
group=group,
|
||
section_key=key,
|
||
defaults={
|
||
'can_create': posted.get(f'create_{key}') in ('1', 'on', 'yes'),
|
||
'can_update': posted.get(f'update_{key}') in ('1', 'on', 'yes'),
|
||
'can_delete': posted.get(f'delete_{key}') in ('1', 'on', 'yes'),
|
||
},
|
||
)
|
||
else:
|
||
group.permissions.remove(perm)
|
||
GroupSectionAccess.objects.filter(group=group, section_key=key).delete()
|
||
_sync_view_all_reports(
|
||
group,
|
||
posted.get('view_all_reports') in ('1', 'on', 'yes'),
|
||
)
|
||
|
||
|
||
def full_access_matrix() -> dict:
|
||
"""Данные полной сетки: разделы (строки) × группы (колонки), галочки просмотра."""
|
||
groups = list(Group.objects.all().order_by('name'))
|
||
group_views = {g.id: _group_view_keys(g) for g in groups}
|
||
rows = []
|
||
for key in matrix_section_keys():
|
||
rows.append({
|
||
'key': key,
|
||
'label': matrix_section_label(key),
|
||
'cells': [{'group_id': g.id, 'checked': key in group_views[g.id]} for g in groups],
|
||
})
|
||
rows.append({
|
||
'key': 'view_all_reports',
|
||
'label': 'Отчёты по всем отделам',
|
||
'cells': [{'group_id': g.id, 'checked': group_can_view_all_reports(g)} for g in groups],
|
||
})
|
||
return {'groups': groups, 'rows': rows}
|
||
|
||
|
||
def apply_full_access_matrix(posted: dict) -> None:
|
||
"""Сохранить просмотр (access_*) по сетке для всех групп.
|
||
|
||
CUD (создание/изменение/удаление) здесь не трогаем — они настраиваются
|
||
на карточке конкретной группы; сетка управляет только доступом на просмотр.
|
||
"""
|
||
perms_by_key = _portal_access_perms_by_key()
|
||
groups = list(Group.objects.all())
|
||
keys = matrix_section_keys()
|
||
with transaction.atomic():
|
||
for g in groups:
|
||
for key in keys:
|
||
perm = perms_by_key.get(key)
|
||
if perm is None:
|
||
continue
|
||
if posted.get(f'g{g.id}_{key}') is not None:
|
||
g.permissions.add(perm)
|
||
else:
|
||
g.permissions.remove(perm)
|
||
GroupSectionAccess.objects.filter(group=g, section_key=key).delete()
|
||
_sync_view_all_reports(g, posted.get(f'g{g.id}_view_all_reports') is not None)
|
||
|
||
|
||
def copy_group_access(source: Group, target: Group) -> None:
|
||
"""Скопировать просмотр (access_*) и CUD с source на target."""
|
||
perms_by_key = _portal_access_perms_by_key()
|
||
keys = matrix_section_keys()
|
||
source_views = _group_view_keys(source)
|
||
source_cud = {
|
||
r.section_key: r
|
||
for r in GroupSectionAccess.objects.filter(group=source)
|
||
}
|
||
|
||
with transaction.atomic():
|
||
for key in keys:
|
||
perm = perms_by_key.get(key)
|
||
if perm is None:
|
||
continue
|
||
if key in source_views:
|
||
target.permissions.add(perm)
|
||
src = source_cud.get(key)
|
||
GroupSectionAccess.objects.update_or_create(
|
||
group=target,
|
||
section_key=key,
|
||
defaults={
|
||
'can_create': True if src is None else src.can_create,
|
||
'can_update': True if src is None else src.can_update,
|
||
'can_delete': True if src is None else src.can_delete,
|
||
},
|
||
)
|
||
else:
|
||
target.permissions.remove(perm)
|
||
GroupSectionAccess.objects.filter(group=target, section_key=key).delete()
|
||
_sync_view_all_reports(target, group_can_view_all_reports(source))
|
||
|
||
|
||
@login_required
|
||
@section_required('user_groups')
|
||
def user_groups_list_view(request):
|
||
groups = list(
|
||
Group.objects.select_related('profile').order_by('name')
|
||
)
|
||
for g in groups:
|
||
profile = getattr(g, 'profile', None)
|
||
g.profile_description = getattr(profile, 'description', '') or ''
|
||
g.profile_active = getattr(profile, 'is_active', True) if profile else True
|
||
g.access_count = g.permissions.filter(codename__startswith='access_').count()
|
||
|
||
context = {
|
||
'groups': groups,
|
||
'individual_report_viewers': list(individual_view_all_report_employees()),
|
||
'employee_choices': list(
|
||
Employee.objects.filter(is_active=True)
|
||
.order_by('last_name', 'first_name', 'username')
|
||
.only('id', 'last_name', 'first_name', 'username')
|
||
),
|
||
'ug_message': request.session.pop('ug_message', ''),
|
||
'ug_error': request.session.pop('ug_error', ''),
|
||
}
|
||
return render_page(request, 'user-groups.html', context)
|
||
|
||
|
||
@login_required
|
||
@section_required('user_groups')
|
||
@require_POST
|
||
def user_group_individual_reports_grant_view(request):
|
||
raw_id = (request.POST.get('employee_id') or '').strip()
|
||
if not raw_id.isdigit():
|
||
request.session['ug_error'] = 'Выберите сотрудника.'
|
||
return redirect('user_groups')
|
||
employee = get_object_or_404(Employee, pk=int(raw_id), is_active=True)
|
||
try:
|
||
grant_individual_view_all_reports(employee)
|
||
except ValueError as exc:
|
||
request.session['ug_error'] = str(exc)
|
||
return redirect('user_groups')
|
||
request.session['ug_message'] = (
|
||
f'«{employee.get_full_name() or employee.username}» может видеть отчёты всех отделов.'
|
||
)
|
||
return redirect('user_groups')
|
||
|
||
|
||
@login_required
|
||
@section_required('user_groups')
|
||
@require_POST
|
||
def user_group_individual_reports_revoke_view(request, employee_id: int):
|
||
employee = get_object_or_404(Employee, pk=employee_id)
|
||
revoke_individual_view_all_reports(employee)
|
||
request.session['ug_message'] = (
|
||
f'Индивидуальный доступ к отчётам всех отделов снят у «{employee.get_full_name() or employee.username}».'
|
||
)
|
||
return redirect('user_groups')
|
||
|
||
|
||
@login_required
|
||
@section_required('user_groups')
|
||
def user_group_access_view(request, group_id: int):
|
||
group = get_object_or_404(Group.objects.select_related('profile'), pk=group_id)
|
||
other_groups = Group.objects.exclude(pk=group.pk).order_by('name')
|
||
context = {
|
||
'group': group,
|
||
'matrix_rows': _matrix_rows(group),
|
||
'view_all_reports': group_can_view_all_reports(group),
|
||
'other_groups': other_groups,
|
||
'ug_message': request.session.pop('ug_message', ''),
|
||
'ug_error': request.session.pop('ug_error', ''),
|
||
}
|
||
return render_page(request, 'user-group-access.html', context)
|
||
|
||
|
||
@login_required
|
||
@section_required('user_groups')
|
||
@require_POST
|
||
def user_group_access_save_view(request, group_id: int):
|
||
group = get_object_or_404(Group, pk=group_id)
|
||
apply_group_access_matrix(group, request.POST)
|
||
request.session['ug_message'] = 'Доступ сохранён.'
|
||
return redirect('user_group_access', group_id=group.pk)
|
||
|
||
|
||
@login_required
|
||
@section_required('user_groups')
|
||
@require_POST
|
||
def user_group_access_copy_view(request, group_id: int):
|
||
source = get_object_or_404(Group, pk=group_id)
|
||
target_id = (request.POST.get('target_group_id') or '').strip()
|
||
if not target_id.isdigit():
|
||
request.session['ug_error'] = 'Выберите группу-получателя.'
|
||
return redirect('user_group_access', group_id=source.pk)
|
||
target = get_object_or_404(Group, pk=int(target_id))
|
||
if target.pk == source.pk:
|
||
request.session['ug_error'] = 'Нельзя копировать группу в саму себя.'
|
||
return redirect('user_group_access', group_id=source.pk)
|
||
copy_group_access(source, target)
|
||
request.session['ug_message'] = f'Доступ скопирован в «{target.name}».'
|
||
return redirect('user_group_access', group_id=target.pk)
|