Files
ACORE/core/user_groups_portal.py
T
lokses 7074244479 admin: полная матрица доступа групп в админке
Кнопка «Матрица доступа» на списке групп (unfold actions_list) открывает
сетку: строки — разделы портала, колонки — все группы, галочки просмотра.
Сохранение через apply_full_access_matrix (CUD не трогает — они на карточке
группы). Логика переиспользована из user_groups_portal. Смоук-тесты рендера
и сохранения.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-21 15:33:25 +03:00

361 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Портал: группы пользователей и сводная таблица доступа."""
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)