documents: права view/edit_all_documents для группы и сотрудника.

Чтобы на портале можно было выдать доступ ко всем документам так же, как к отчётам всех отделов.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-23 14:04:51 +03:00
parent 50825ce505
commit 69ae14f65e
11 changed files with 511 additions and 12 deletions
+10
View File
@@ -105,6 +105,16 @@ urlpatterns = [
user_groups_portal.user_group_individual_reports_revoke_view,
name='user_group_individual_reports_revoke',
),
path(
'user-groups/individual-documents/',
user_groups_portal.user_group_individual_documents_grant_view,
name='user_group_individual_documents_grant',
),
path(
'user-groups/individual-documents/<int:employee_id>/revoke/',
user_groups_portal.user_group_individual_documents_revoke_view,
name='user_group_individual_documents_revoke',
),
path('user-groups/<int:group_id>/access/', user_groups_portal.user_group_access_view, name='user_group_access'),
path('user-groups/<int:group_id>/access/save/', user_groups_portal.user_group_access_save_view, name='user_group_access_save'),
path('user-groups/<int:group_id>/access/copy/', user_groups_portal.user_group_access_copy_view, name='user_group_access_copy'),
+6 -2
View File
@@ -22,11 +22,15 @@ from apps.visit_manager.models import WorkSchedule, AttendanceLog
def _portal_permissions_qs():
ct = ContentType.objects.get_for_model(PortalAccess, for_concrete_model=False)
access = Permission.objects.filter(content_type=ct, codename__startswith='access_')
extra = Permission.objects.filter(
reports = Permission.objects.filter(
content_type__app_label='reports',
codename='view_all_departments',
)
return (access | extra).order_by('content_type__app_label', 'codename')
documents = Permission.objects.filter(
content_type__app_label='nextcloud_connector',
codename__in=('view_all_documents', 'edit_all_documents'),
)
return (access | reports | documents).order_by('content_type__app_label', 'codename')
def _pnms_permissions_qs():
+24 -7
View File
@@ -22,7 +22,11 @@ def can_access_pharmacy_docs(user, pharmacy) -> bool:
"""Кто вправе видеть документы конкретной аптеки (список, скачать, OnlyOffice view).
Политика из PharmacyCardVisibility.show_documents (админка).
Глобальне право view/edit_all_documents відкриває всі аптеки.
"""
from apps.nextcloud_connector.doc_acl import LEVEL_VIEW, user_documents_global_level
if user_documents_global_level(user) >= LEVEL_VIEW:
return bool(pharmacy and getattr(pharmacy, 'pk', None))
from apps.locations.models.pharmacy_visibility import (
PharmacyCardVisibility, user_can_view_pharmacy_section,
)
@@ -33,8 +37,12 @@ def can_access_pharmacy_docs(user, pharmacy) -> bool:
def can_edit_pharmacy_docs(user, pharmacy) -> bool:
"""Кто вправе менять документы аптеки (upload/mkdir/rename/delete, OnlyOffice edit).
Совпадает с правом редактирования карточки (PharmacyCardVisibility.edit_pharmacy).
Совпадает с правом редактирования карточки (PharmacyCardVisibility.edit_pharmacy),
або edit_all_documents.
"""
from apps.nextcloud_connector.doc_acl import LEVEL_EDIT, user_documents_global_level
if user_documents_global_level(user) >= LEVEL_EDIT:
return bool(pharmacy and getattr(pharmacy, 'pk', None))
from apps.locations.models.pharmacy_visibility import user_can_edit_pharmacy
return user_can_edit_pharmacy(user, pharmacy)
@@ -145,7 +153,12 @@ def accessible_sections(user) -> list[dict]:
if not getattr(user, 'is_authenticated', False):
return []
is_staff = bool(user.is_staff or user.is_superuser)
from apps.nextcloud_connector.doc_acl import (
LEVEL_EDIT, LEVEL_VIEW, user_documents_global_level,
)
from apps.nextcloud_connector.services.sync_service import DEPT_ROOT
global_lvl = user_documents_global_level(user)
can_write_global = is_staff or global_lvl >= LEVEL_EDIT
sections = []
if user_has_mine_tab(user):
sections.append({
@@ -155,13 +168,17 @@ def accessible_sections(user) -> list[dict]:
'writable': False, # факт записи — по ACL своего отдела
})
sections.extend([
{'key': 'company', 'label': 'Компания', 'relpath': '01_Компания', 'writable': is_staff},
{'key': 'org', 'label': 'Структура', 'relpath': DEPT_ROOT, 'writable': is_staff},
{'key': 'training', 'label': 'Обучение', 'relpath': '05_Обучение', 'writable': is_staff},
{'key': 'company', 'label': 'Компания', 'relpath': '01_Компания', 'writable': can_write_global},
{'key': 'org', 'label': 'Структура', 'relpath': DEPT_ROOT, 'writable': can_write_global},
{'key': 'training', 'label': 'Обучение', 'relpath': '05_Обучение', 'writable': can_write_global},
])
if user.is_superuser:
sections.append({'key': 'hr', 'label': 'Кадровые дела',
'relpath': '04_Кадровые дела', 'writable': True})
if user.is_superuser or global_lvl >= LEVEL_VIEW:
sections.append({
'key': 'hr',
'label': 'Кадровые дела',
'relpath': '04_Кадровые дела',
'writable': bool(user.is_superuser or global_lvl >= LEVEL_EDIT),
})
return sections
+35
View File
@@ -20,6 +20,31 @@ LEVEL_CODE = {
}
LEVEL_NAME = {LEVEL_VIEW: 'view', LEVEL_EDIT: 'edit'}
# Глобальний доступ (група / індивідуально) — як locations.view_all_locations.
VIEW_ALL_DOCUMENTS_PERM = 'nextcloud_connector.view_all_documents'
EDIT_ALL_DOCUMENTS_PERM = 'nextcloud_connector.edit_all_documents'
def user_documents_global_level(user) -> int:
"""Рівень «усі документи» з права Django (група або override).
staff/superuser тут не обробляємо — їх уже покриває resolve_access.
"""
if not user or not getattr(user, 'is_authenticated', False):
return LEVEL_NONE
preview = getattr(user, '_access_preview_perms', None)
if preview is not None:
if EDIT_ALL_DOCUMENTS_PERM in preview:
return LEVEL_EDIT
if VIEW_ALL_DOCUMENTS_PERM in preview:
return LEVEL_VIEW
return LEVEL_NONE
if user.has_perm(EDIT_ALL_DOCUMENTS_PERM):
return LEVEL_EDIT
if user.has_perm(VIEW_ALL_DOCUMENTS_PERM):
return LEVEL_VIEW
return LEVEL_NONE
def normalize_path(path: str | None) -> str:
return (path or '').strip().strip('/')
@@ -145,6 +170,8 @@ def resolve_access(user, *, section_key: str, path: str = '',
if user.is_superuser or user.is_staff:
return AccessDecision(LEVEL_EDIT)
global_lvl = user_documents_global_level(user)
path = normalize_path(path)
prefixes = path_prefixes(path)
@@ -160,9 +187,14 @@ def resolve_access(user, *, section_key: str, path: str = '',
elif section_key in ('company', 'training'):
# читання всім з доступом до розділу, запис — staff (вже вище) або грант
default = LEVEL_VIEW
elif section_key == 'hr' and global_lvl >= LEVEL_VIEW:
default = global_lvl
else:
default = LEVEL_NONE
if global_lvl > default:
default = global_lvl
from apps.nextcloud_connector.models import DocumentAccessGrant
qs = DocumentAccessGrant.objects.filter(
@@ -185,6 +217,9 @@ def resolve_access(user, *, section_key: str, path: str = '',
if plen > best_len or (plen == best_len and lvl > best):
best_len = plen
best = lvl
# Глобальне view/edit_all_documents — підлога, шляховий грант не знижує.
if global_lvl > best:
best = global_lvl
return AccessDecision(best)
@@ -0,0 +1,31 @@
# Generated by Django 6.0.5 on 2026-10-23 11:01
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('nextcloud_connector', '0002_document_access_grant'),
]
operations = [
migrations.AlterModelOptions(
name='documentaccessgrant',
options={
'ordering': ['section_key', 'department_id', 'path', 'level'],
'permissions': [
(
'view_all_documents',
'Перегляд усіх документів (усі відділи, розділи, аптеки)',
),
(
'edit_all_documents',
'Редагування усіх документів (усі відділи, розділи, аптеки)',
),
],
'verbose_name': 'Право на документ',
'verbose_name_plural': 'Права на документи',
},
),
]
+14 -1
View File
@@ -70,7 +70,20 @@ class DocumentAccessGrant(BaseModel):
verbose_name_plural = _('Права на документи')
ordering = ['section_key', 'department_id', 'path', 'level']
indexes = [
models.Index(fields=['section_key', 'department', 'path']),
models.Index(
fields=['section_key', 'department', 'path'],
name='nextcloud_c_section_7c2a1d_idx',
),
]
permissions = [
(
'view_all_documents',
_('Перегляд усіх документів (усі відділи, розділи, аптеки)'),
),
(
'edit_all_documents',
_('Редагування усіх документів (усі відділи, розділи, аптеки)'),
),
]
def __str__(self):
+77
View File
@@ -847,3 +847,80 @@ class BlankOfficeCreateTests(TestCase):
self.assertIn('wordprocessingml', office_content_type('a.docx'))
self.assertIn('spreadsheetml', office_content_type('a.xlsx'))
class DocumentGlobalAccessTests(TestCase):
"""view_all_documents / edit_all_documents — як view_all_reports для документів."""
@classmethod
def setUpTestData(cls):
from django.contrib.auth.models import Permission
dtype = DirectoryType.objects.create(code='department', name='Відділ')
cls.dept_a = Directory.objects.create(directory_type=dtype, code='it', name='IT')
cls.dept_b = Directory.objects.create(directory_type=dtype, code='hr', name='HR')
org_type = DirectoryType.objects.create(code='organization', name='Орг')
region_type = DirectoryType.objects.create(code='region', name='Регион')
region = Directory.objects.create(directory_type=region_type, code='od', name='Одесса')
org = Directory.objects.create(directory_type=org_type, code='akc', name='АКС')
cls.ph_a = Pharmacy.objects.create(
organization=org, location_region=region,
pharmacy_number=71, location_type='pharmacy',
)
cls.ph_b = Pharmacy.objects.create(
organization=org, location_region=region,
pharmacy_number=72, location_type='pharmacy',
)
common = dict(
mattermost_sync_enabled=False, ad_sync_enabled=False,
hire_date=datetime.date(2020, 1, 1),
user_location_type='office',
department=cls.dept_a,
)
cls.user = Employee.objects.create_user(username='docs_global', **common)
cls.view_perm = Permission.objects.get(
content_type__app_label='nextcloud_connector',
codename='view_all_documents',
)
cls.edit_perm = Permission.objects.get(
content_type__app_label='nextcloud_connector',
codename='edit_all_documents',
)
def test_view_all_opens_other_dept_and_hr_and_pharmacies(self):
from apps.nextcloud_connector.access import accessible_sections
from apps.nextcloud_connector.doc_acl import resolve_access
self.assertFalse(
resolve_access(
self.user, section_key='org', path='', department_id=self.dept_b.pk,
).can_view,
)
self.user.user_permissions.add(self.view_perm)
user = Employee.objects.get(pk=self.user.pk)
other = resolve_access(
user, section_key='org', path='secret.docx', department_id=self.dept_b.pk,
)
self.assertTrue(other.can_view)
self.assertFalse(other.can_edit)
hr = resolve_access(user, section_key='hr', path='')
self.assertTrue(hr.can_view)
self.assertFalse(hr.can_edit)
self.assertTrue(can_access_pharmacy_docs(user, self.ph_b))
self.assertFalse(can_edit_pharmacy_docs(user, self.ph_b))
keys = {s['key'] for s in accessible_sections(user)}
self.assertIn('hr', keys)
def test_edit_all_raises_to_edit(self):
from apps.nextcloud_connector.doc_acl import resolve_access
self.user.user_permissions.add(self.view_perm, self.edit_perm)
user = Employee.objects.get(pk=self.user.pk)
dec = resolve_access(
user, section_key='org', path='', department_id=self.dept_b.pk,
)
self.assertTrue(dec.can_edit)
hr = resolve_access(user, section_key='hr', path='x.docx')
self.assertTrue(hr.can_edit)
self.assertTrue(can_edit_pharmacy_docs(user, self.ph_a))
self.assertTrue(can_edit_pharmacy_docs(user, self.ph_b))
+14 -2
View File
@@ -58,8 +58,14 @@ MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 МБ
def _get_pharmacy_or_403(request, pk):
"""Возвращает (pharmacy, None) или (None, HttpResponseForbidden)."""
from apps.locations.models import Pharmacy
from apps.nextcloud_connector.doc_acl import LEVEL_VIEW, user_documents_global_level
from core.location_access import accessible_pharmacies
pharmacy = get_object_or_404(accessible_pharmacies(request.user), pk=pk)
if user_documents_global_level(request.user) >= LEVEL_VIEW:
pharmacy = get_object_or_404(Pharmacy.objects.all(), pk=pk)
else:
pharmacy = get_object_or_404(accessible_pharmacies(request.user), pk=pk)
if not can_access_pharmacy_docs(request.user, pharmacy):
return None, HttpResponseForbidden('Нет доступа к документам этой аптеки.')
return pharmacy, None
@@ -865,11 +871,17 @@ def _resolve_token_relpath(payload: dict) -> tuple[str, str, bool] | None:
return None
if kind == 'pharmacy':
from django.contrib.auth import get_user_model
from apps.locations.models import Pharmacy
from apps.nextcloud_connector.doc_acl import LEVEL_VIEW, user_documents_global_level
from core.location_access import accessible_pharmacies
user = get_user_model().objects.filter(pk=payload.get('uid'), is_active=True).first()
if user is None:
return None
pharmacy = accessible_pharmacies(user).filter(pk=payload.get('pharmacy_id')).first()
ph_id = payload.get('pharmacy_id')
if user_documents_global_level(user) >= LEVEL_VIEW:
pharmacy = Pharmacy.objects.filter(pk=ph_id).first()
else:
pharmacy = accessible_pharmacies(user).filter(pk=ph_id).first()
if pharmacy is None or not can_access_pharmacy_docs(user, pharmacy):
return None
writable = can_edit_pharmacy_docs(user, pharmacy) and can_edit_file(name)
+229
View File
@@ -48,6 +48,166 @@ def _sync_view_all_reports(group: Group, enabled: bool) -> None:
group.permissions.remove(perm)
def _documents_perm(codename: str) -> Permission | None:
return Permission.objects.filter(
content_type__app_label='nextcloud_connector',
codename=codename,
).first()
def _view_all_documents_perm() -> Permission | None:
return _documents_perm('view_all_documents')
def _edit_all_documents_perm() -> Permission | None:
return _documents_perm('edit_all_documents')
def group_can_view_all_documents(group: Group) -> bool:
return group.permissions.filter(
content_type__app_label='nextcloud_connector',
codename='view_all_documents',
).exists()
def group_can_edit_all_documents(group: Group) -> bool:
return group.permissions.filter(
content_type__app_label='nextcloud_connector',
codename='edit_all_documents',
).exists()
def _sync_documents_global(group: Group, *, view: bool, edit: bool) -> None:
"""edit_all передбачає view_all."""
view_perm = _view_all_documents_perm()
edit_perm = _edit_all_documents_perm()
if edit:
view = True
if view_perm is not None:
if view:
group.permissions.add(view_perm)
else:
group.permissions.remove(view_perm)
if edit_perm is not None:
if edit:
group.permissions.add(edit_perm)
else:
group.permissions.remove(edit_perm)
def _employee_has_perm_or_grant(employee, perm: Permission | None) -> bool:
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 employee_documents_global_level(employee) -> str | None:
"""'edit' | 'view' | None — індивідуальне право на всі документи."""
if _employee_has_perm_or_grant(employee, _edit_all_documents_perm()):
return 'edit'
if _employee_has_perm_or_grant(employee, _view_all_documents_perm()):
return 'view'
return None
def _set_individual_perm_grant(employee, perm: Permission, *, comment: str) -> None:
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 = comment
row.save(update_fields=['is_active', 'comment', 'updated_at'])
else:
EmployeePermissionOverride.objects.create(
employee=employee,
permission=perm,
override_type='grant',
comment=comment,
)
def _clear_individual_perm(employee, perm: Permission | None) -> None:
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 grant_individual_documents_global(employee, level: str = 'view') -> None:
"""level: 'view' | 'edit'."""
view_perm = _view_all_documents_perm()
edit_perm = _edit_all_documents_perm()
if view_perm is None or edit_perm is None:
raise ValueError(
'Права «усі документи» ще не створені. Застосуйте міграції nextcloud_connector.'
)
if level == 'edit':
_set_individual_perm_grant(
employee, view_perm, comment='Перегляд усіх документів',
)
_set_individual_perm_grant(
employee, edit_perm, comment='Редагування усіх документів',
)
else:
_clear_individual_perm(employee, edit_perm)
_set_individual_perm_grant(
employee, view_perm, comment='Перегляд усіх документів',
)
def revoke_individual_documents_global(employee) -> None:
_clear_individual_perm(employee, _view_all_documents_perm())
_clear_individual_perm(employee, _edit_all_documents_perm())
def individual_documents_global_employees():
view_perm = _view_all_documents_perm()
edit_perm = _edit_all_documents_perm()
if view_perm is None and edit_perm is None:
return []
from django.db.models import Q
q = Q()
for perm in (view_perm, edit_perm):
if perm is None:
continue
q |= Q(user_permissions=perm) | Q(
permission_overrides__permission=perm,
permission_overrides__override_type='grant',
permission_overrides__is_active=True,
)
employees = (
Employee.objects.filter(is_active=True)
.filter(q)
.distinct()
.order_by('last_name', 'first_name', 'username')
)
return [
{'employee': emp, 'level': employee_documents_global_level(emp) or 'view'}
for emp in employees
]
def employee_has_individual_view_all_reports(employee) -> bool:
perm = _view_all_reports_perm()
if perm is None:
@@ -181,6 +341,11 @@ def apply_group_access_matrix(group: Group, posted: dict) -> None:
group,
posted.get('view_all_reports') in ('1', 'on', 'yes'),
)
_sync_documents_global(
group,
view=posted.get('view_all_documents') in ('1', 'on', 'yes'),
edit=posted.get('edit_all_documents') in ('1', 'on', 'yes'),
)
def full_access_matrix() -> dict:
@@ -199,6 +364,20 @@ def full_access_matrix() -> dict:
'label': 'Отчёты по всем отделам',
'cells': [{'group_id': g.id, 'checked': group_can_view_all_reports(g)} for g in groups],
})
rows.append({
'key': 'view_all_documents',
'label': 'Документы: просмотр всех',
'cells': [
{'group_id': g.id, 'checked': group_can_view_all_documents(g)} for g in groups
],
})
rows.append({
'key': 'edit_all_documents',
'label': 'Документы: редактирование всех',
'cells': [
{'group_id': g.id, 'checked': group_can_edit_all_documents(g)} for g in groups
],
})
return {'groups': groups, 'rows': rows}
@@ -223,6 +402,11 @@ def apply_full_access_matrix(posted: dict) -> None:
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)
_sync_documents_global(
g,
view=posted.get(f'g{g.id}_view_all_documents') is not None,
edit=posted.get(f'g{g.id}_edit_all_documents') is not None,
)
def copy_group_access(source: Group, target: Group) -> None:
@@ -256,6 +440,11 @@ def copy_group_access(source: Group, target: Group) -> None:
target.permissions.remove(perm)
GroupSectionAccess.objects.filter(group=target, section_key=key).delete()
_sync_view_all_reports(target, group_can_view_all_reports(source))
_sync_documents_global(
target,
view=group_can_view_all_documents(source),
edit=group_can_edit_all_documents(source),
)
@login_required
@@ -273,6 +462,7 @@ def user_groups_list_view(request):
context = {
'groups': groups,
'individual_report_viewers': list(individual_view_all_report_employees()),
'individual_document_grants': individual_documents_global_employees(),
'employee_choices': list(
Employee.objects.filter(is_active=True)
.order_by('last_name', 'first_name', 'username')
@@ -316,6 +506,43 @@ def user_group_individual_reports_revoke_view(request, employee_id: int):
return redirect('user_groups')
@login_required
@section_required('user_groups')
@require_POST
def user_group_individual_documents_grant_view(request):
raw_id = (request.POST.get('employee_id') or '').strip()
level = (request.POST.get('level') or 'view').strip().lower()
if level not in ('view', 'edit'):
level = 'view'
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_documents_global(employee, level=level)
except ValueError as exc:
request.session['ug_error'] = str(exc)
return redirect('user_groups')
label = 'редактировать' if level == 'edit' else 'видеть'
request.session['ug_message'] = (
f'«{employee.get_full_name() or employee.username}» может {label} все документы.'
)
return redirect('user_groups')
@login_required
@section_required('user_groups')
@require_POST
def user_group_individual_documents_revoke_view(request, employee_id: int):
employee = get_object_or_404(Employee, pk=employee_id)
revoke_individual_documents_global(employee)
request.session['ug_message'] = (
f'Индивидуальный доступ ко всем документам снят у '
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):
@@ -325,6 +552,8 @@ def user_group_access_view(request, group_id: int):
'group': group,
'matrix_rows': _matrix_rows(group),
'view_all_reports': group_can_view_all_reports(group),
'view_all_documents': group_can_view_all_documents(group),
'edit_all_documents': group_can_edit_all_documents(group),
'other_groups': other_groups,
'ug_message': request.session.pop('ug_message', ''),
'ug_error': request.session.pop('ug_error', ''),
+22
View File
@@ -92,6 +92,28 @@
</label>
</div>
<div style="margin-top:1rem; background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); padding:1rem 1.25rem;">
<div style="font-size:0.7rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--color-muted-foreground); margin-bottom:0.5rem;">{% trans "Документы" %}</div>
<label style="display:flex; align-items:flex-start; gap:0.6rem; cursor:pointer; font-size:0.875rem; margin-bottom:0.75rem;">
<input type="checkbox" name="view_all_documents" value="1" {% if view_all_documents or edit_all_documents %}checked{% endif %} style="margin-top:0.2rem;">
<span>
<strong>{% trans "Просмотр всех документов" %}</strong>
<span style="display:block; font-size:0.8125rem; color:var(--color-muted-foreground); margin-top:0.2rem;">
{% trans "Все отделы, разделы, кадровые дела и документы аптек. Без галочки — только свой отдел и выданные ACL." %}
</span>
</span>
</label>
<label style="display:flex; align-items:flex-start; gap:0.6rem; cursor:pointer; font-size:0.875rem;">
<input type="checkbox" name="edit_all_documents" value="1" {% if edit_all_documents %}checked{% endif %} style="margin-top:0.2rem;">
<span>
<strong>{% trans "Редактирование всех документов" %}</strong>
<span style="display:block; font-size:0.8125rem; color:var(--color-muted-foreground); margin-top:0.2rem;">
{% trans "Включает просмотр. Загрузка, изменение и удаление во всех разделах и аптеках." %}
</span>
</span>
</label>
</div>
<div style="display:flex; gap:0.75rem; margin-top:1.25rem; flex-wrap:wrap;">
<a href="{% url 'user_groups' %}"
hx-get="{% url 'user_groups' %}" hx-target="#main-content" hx-push-url="{% url 'user_groups' %}"
+49
View File
@@ -93,4 +93,53 @@
<p style="font-size:0.8125rem; color:var(--color-muted-foreground); margin:0;">{% trans "Пока никому персонально не выдано." %}</p>
{% endif %}
</div>
<div style="margin-top:1.5rem; background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); padding:1.25rem;">
<h2 style="font-size:0.9375rem; font-weight:500; margin:0 0 0.35rem;">{% trans "Все документы — отдельным сотрудникам" %}</h2>
<p style="font-size:0.8125rem; color:var(--color-muted-foreground); margin:0 0 1rem;">
{% trans "Не трогает группу: право только у выбранного человека. Можно выдать просмотр или полное редактирование всех документов." %}
</p>
<form method="post" action="{% url 'user_group_individual_documents_grant' %}"
style="display:flex; gap:0.5rem; flex-wrap:wrap; align-items:center; margin:0 0 1rem;">
{% csrf_token %}
<select name="employee_id" required class="input" style="min-width:18rem;">
<option value="">{% trans "Выберите сотрудника…" %}</option>
{% for emp in employee_choices %}
<option value="{{ emp.id }}">{{ emp.last_name }} {{ emp.first_name }} ({{ emp.username }})</option>
{% endfor %}
</select>
<select name="level" class="input" style="min-width:10rem;">
<option value="view">{% trans "Просмотр" %}</option>
<option value="edit">{% trans "Редактирование" %}</option>
</select>
<button type="submit" class="btn btn-primary">{% trans "Выдать доступ" %}</button>
</form>
{% if individual_document_grants %}
<table style="width:100%; border-collapse:collapse; font-size:0.8125rem;">
<thead>
<tr style="background:var(--color-muted);">
<th style="padding:0.5rem 0.75rem; text-align:left; font-size:0.7rem; text-transform:uppercase; color:var(--color-muted-foreground);">{% trans "Сотрудник" %}</th>
<th style="padding:0.5rem 0.75rem; text-align:left; font-size:0.7rem; text-transform:uppercase; color:var(--color-muted-foreground);">{% trans "Уровень" %}</th>
<th style="padding:0.5rem 0.75rem;"></th>
</tr>
</thead>
<tbody>
{% for row in individual_document_grants %}
<tr style="border-top:1px solid var(--color-border);">
<td style="padding:0.6rem 0.75rem;">{{ row.employee.last_name }} {{ row.employee.first_name }} <span style="color:var(--color-muted-foreground);">({{ row.employee.username }})</span></td>
<td style="padding:0.6rem 0.75rem;">{% if row.level == 'edit' %}{% trans "Редактирование" %}{% else %}{% trans "Просмотр" %}{% endif %}</td>
<td style="padding:0.6rem 0.75rem; text-align:right;">
<form method="post" action="{% url 'user_group_individual_documents_revoke' row.employee.id %}" style="margin:0;">
{% csrf_token %}
<button type="submit" class="btn btn-secondary" style="font-size:0.75rem;">{% trans "Снять" %}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="font-size:0.8125rem; color:var(--color-muted-foreground); margin:0;">{% trans "Пока никому персонально не выдано." %}</p>
{% endif %}
</div>
</div>