6af7a210f2
Co-authored-by: Cursor <cursoragent@cursor.com>
313 lines
12 KiB
Python
313 lines
12 KiB
Python
"""Авторизация доступа к документам аптек и общего раздела /documents/.
|
|
|
|
Ключевой инвариант безопасности: путь к папке аптеки выводится ТОЛЬКО из
|
|
объекта Pharmacy на сервере (никогда из данных клиента), а имя файла проходит
|
|
safe_filename() — так исключён обход каталога (path traversal) и переход в чужую
|
|
аптеку. Сервисный аккаунт технически видит все папки аптек, поэтому весь скоуп
|
|
держится здесь, на стороне портала.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import posixpath
|
|
|
|
from apps.nextcloud_connector.services.sync_service import pharmacy_mountpoint
|
|
|
|
|
|
def pharmacy_folder_relpath(pharmacy) -> str:
|
|
"""Относительный путь папки аптеки в DAV-дереве сервисного аккаунта."""
|
|
return pharmacy_mountpoint(pharmacy)
|
|
|
|
|
|
def can_access_pharmacy_docs(user, pharmacy) -> bool:
|
|
"""Кто вправе видеть документы конкретной аптеки (список, скачать, OnlyOffice view).
|
|
|
|
Политика из PharmacyCardVisibility.show_documents (админка).
|
|
"""
|
|
from apps.locations.models.pharmacy_visibility import (
|
|
PharmacyCardVisibility, user_can_view_pharmacy_section,
|
|
)
|
|
policy = PharmacyCardVisibility.load()
|
|
return user_can_view_pharmacy_section(user, pharmacy, policy.show_documents)
|
|
|
|
|
|
def can_edit_pharmacy_docs(user, pharmacy) -> bool:
|
|
"""Кто вправе менять документы аптеки (upload/mkdir/rename/delete, OnlyOffice edit).
|
|
|
|
Совпадает с правом редактирования карточки (PharmacyCardVisibility.edit_pharmacy).
|
|
"""
|
|
from apps.locations.models.pharmacy_visibility import user_can_edit_pharmacy
|
|
return user_can_edit_pharmacy(user, pharmacy)
|
|
|
|
|
|
# --- Общий браузер документов (страница /documents/) ---
|
|
|
|
_SECTION_LABELS = {
|
|
'company': 'Компания',
|
|
'org': 'Структура',
|
|
'training': 'Обучение',
|
|
'hr': 'Кадровые дела',
|
|
'mine': 'Мой отдел',
|
|
}
|
|
|
|
|
|
def user_has_mine_tab(user) -> bool:
|
|
"""Вкладка «Мой отдел»: есть свой отдел или дополнительные ACL-гранты."""
|
|
if not getattr(user, 'is_authenticated', False):
|
|
return False
|
|
if getattr(user, 'department_id', None):
|
|
return True
|
|
from apps.nextcloud_connector.doc_acl import _subject_q
|
|
from apps.nextcloud_connector.models import DocumentAccessGrant
|
|
return DocumentAccessGrant.objects.filter(_subject_q(user), is_active=True).exists()
|
|
|
|
|
|
def additional_share_targets(user) -> list[dict]:
|
|
"""Доп. доступные каталоги/файлы по ACL (вне корня собственного отдела).
|
|
|
|
Каждый элемент — виртуальная запись для списка «Мой отдел».
|
|
"""
|
|
if not getattr(user, 'is_authenticated', False):
|
|
return []
|
|
from apps.nextcloud_connector.doc_acl import LEVEL_CODE, _subject_q, normalize_path
|
|
from apps.nextcloud_connector.models import DocumentAccessGrant
|
|
|
|
own_dept = getattr(user, 'department_id', None)
|
|
qs = (
|
|
DocumentAccessGrant.objects.filter(_subject_q(user), is_active=True)
|
|
.select_related('department')
|
|
.order_by('section_key', 'department_id', 'path')
|
|
)
|
|
# Схлопываем дубликаты (section, dept, path) → max level
|
|
best: dict[tuple, dict] = {}
|
|
for g in qs:
|
|
path = normalize_path(g.path)
|
|
# Корень своего отдела и так открыт во вкладке — не дублируем
|
|
if (
|
|
g.section_key == 'org'
|
|
and own_dept
|
|
and g.department_id
|
|
and int(g.department_id) == int(own_dept)
|
|
and not path
|
|
):
|
|
continue
|
|
key = (g.section_key, g.department_id, path)
|
|
lvl = LEVEL_CODE.get(g.level, 0)
|
|
prev = best.get(key)
|
|
if prev is None or lvl > prev['level_rank']:
|
|
dept_name = g.department.name if g.department_id else ''
|
|
section_label = _SECTION_LABELS.get(g.section_key, g.section_key)
|
|
if g.section_key == 'org' and dept_name:
|
|
label = dept_name if not path else f'{dept_name} / {path}'
|
|
elif path:
|
|
label = f'{section_label} / {path}'
|
|
else:
|
|
label = section_label
|
|
# Файл: path + імʼя окремо — кліком відкриваємо одразу, не browse
|
|
file_name = ''
|
|
parent_path = path
|
|
is_file = not bool(g.is_folder) and bool(path)
|
|
if is_file:
|
|
if '/' in path:
|
|
parent_path, file_name = path.rsplit('/', 1)
|
|
else:
|
|
parent_path, file_name = '', path
|
|
best[key] = {
|
|
'name': label,
|
|
'file_name': file_name,
|
|
'is_dir': not is_file,
|
|
'is_shared_target': True,
|
|
'share_section': g.section_key,
|
|
'dept_id': g.department_id,
|
|
'path': parent_path,
|
|
'size': None,
|
|
'can_view': True,
|
|
'can_edit': lvl >= 2,
|
|
'level_rank': lvl,
|
|
}
|
|
# Стабильный порядок: org с отделами, потом остальные
|
|
items = list(best.values())
|
|
for it in items:
|
|
it.pop('level_rank', None)
|
|
items.sort(key=lambda x: (
|
|
0 if x['share_section'] == 'org' else 1,
|
|
x['name'].lower(),
|
|
))
|
|
return items
|
|
|
|
|
|
def accessible_sections(user) -> list[dict]:
|
|
"""Корневые разделы хранилища, доступные пользователю.
|
|
|
|
Каждый раздел — {key, label, relpath, writable}. Ключ приходит от клиента,
|
|
но раздел резолвится ТОЛЬКО из этого списка (get_section) — произвольный путь
|
|
подставить нельзя. Права на запись считаются здесь, а не на клиенте.
|
|
"""
|
|
if not getattr(user, 'is_authenticated', False):
|
|
return []
|
|
is_staff = bool(user.is_staff or user.is_superuser)
|
|
from apps.nextcloud_connector.services.sync_service import DEPT_ROOT
|
|
sections = []
|
|
if user_has_mine_tab(user):
|
|
sections.append({
|
|
'key': 'mine',
|
|
'label': 'Мой отдел',
|
|
'relpath': '',
|
|
'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},
|
|
])
|
|
if user.is_superuser:
|
|
sections.append({'key': 'hr', 'label': 'Кадровые дела',
|
|
'relpath': '04_Кадровые дела', 'writable': True})
|
|
return sections
|
|
|
|
|
|
def get_section(user, key: str) -> dict | None:
|
|
for s in accessible_sections(user):
|
|
if s['key'] == key:
|
|
return dict(s)
|
|
return None
|
|
|
|
|
|
def acl_section_key(section: dict) -> str:
|
|
"""Ключ для DocumentAccessGrant / resolve_access (mine → org)."""
|
|
return section.get('acl_key') or section.get('key') or ''
|
|
|
|
|
|
def department_tree_for_user(user) -> list[dict]:
|
|
"""Повне дерево департаментів для сайдбару (метадані з Directory)."""
|
|
from core.models import Directory
|
|
|
|
if not getattr(user, 'is_authenticated', False):
|
|
return []
|
|
nodes = list(
|
|
Directory.objects.filter(directory_type__code='department', is_active=True)
|
|
.order_by('_path', 'name')
|
|
.only('pk', 'name', 'code', 'parent_id', '_depth', '_path')
|
|
)
|
|
by_parent: dict[int | None, list] = {}
|
|
for n in nodes:
|
|
by_parent.setdefault(n.parent_id, []).append(n)
|
|
|
|
def walk(parent_id):
|
|
out = []
|
|
for n in by_parent.get(parent_id, []):
|
|
out.append({
|
|
'id': n.pk,
|
|
'name': n.name,
|
|
'code': n.code,
|
|
'depth': getattr(n, '_depth', 0) or 0,
|
|
'children': walk(n.pk),
|
|
})
|
|
return out
|
|
|
|
return walk(None)
|
|
|
|
|
|
def _employee_picker_dict(emp) -> dict:
|
|
return {
|
|
'pk': emp.pk,
|
|
'label': emp.get_full_name() or emp.username or '—',
|
|
'position': emp.post.name if getattr(emp, 'post_id', None) else '',
|
|
'photo': emp.photo.url if getattr(emp, 'photo', None) else '',
|
|
}
|
|
|
|
|
|
def people_picker_payload() -> dict:
|
|
"""Дані для people-picker: плоский options + дерево з співробітниками."""
|
|
from django.contrib.auth import get_user_model
|
|
from core.models import Directory
|
|
|
|
Employee = get_user_model()
|
|
depts = list(
|
|
Directory.objects.filter(directory_type__code='department', is_active=True)
|
|
.select_related('parent')
|
|
.order_by('_path', 'name')
|
|
)
|
|
employees = list(
|
|
Employee.objects.filter(is_active=True)
|
|
.select_related('post', 'department')
|
|
.order_by('last_name', 'first_name')
|
|
)
|
|
by_dept: dict = {}
|
|
for emp in employees:
|
|
by_dept.setdefault(emp.department_id, []).append(emp)
|
|
|
|
nodes = {
|
|
d.pk: {
|
|
'pk': d.pk,
|
|
'name': d.name,
|
|
'children': [],
|
|
'employees': [_employee_picker_dict(e) for e in by_dept.get(d.pk, [])],
|
|
}
|
|
for d in depts
|
|
}
|
|
roots = []
|
|
for d in depts:
|
|
node = nodes[d.pk]
|
|
parent = nodes.get(d.parent_id)
|
|
if parent is not None:
|
|
parent['children'].append(node)
|
|
else:
|
|
roots.append(node)
|
|
|
|
return {
|
|
'employee_options': [_employee_picker_dict(e) for e in employees],
|
|
'people_picker_tree': {
|
|
'tree': roots,
|
|
'no_department': [_employee_picker_dict(e) for e in by_dept.get(None, [])],
|
|
},
|
|
'department_options': [{'pk': d.pk, 'label': d.name} for d in depts],
|
|
}
|
|
|
|
|
|
def resolve_department(dept_id) -> object | None:
|
|
if not dept_id:
|
|
return None
|
|
try:
|
|
pk = int(dept_id)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
from core.models import Directory
|
|
return Directory.objects.filter(
|
|
pk=pk, directory_type__code='department', is_active=True,
|
|
).first()
|
|
|
|
|
|
def safe_relpath(subpath: str | None) -> str | None:
|
|
"""Разбирает подпуть внутри раздела. '' → корень. None, если небезопасно.
|
|
|
|
Каждый сегмент проходит safe_filename — обход каталога (.., абсолютные пути)
|
|
исключён, выйти за пределы раздела нельзя.
|
|
"""
|
|
if not subpath:
|
|
return ''
|
|
parts = subpath.strip('/').split('/')
|
|
clean = []
|
|
for p in parts:
|
|
if safe_filename(p) is None:
|
|
return None
|
|
clean.append(p)
|
|
return '/'.join(clean)
|
|
|
|
|
|
def safe_filename(name: str | None) -> str | None:
|
|
"""Очищенное имя файла или None, если оно небезопасно.
|
|
|
|
Отклоняет разделители пути, '.'/'..', скрытые файлы и NUL — чтобы никакой
|
|
ввод пользователя не смог выйти за пределы папки аптеки.
|
|
"""
|
|
if not name:
|
|
return None
|
|
name = name.strip()
|
|
if not name or name in ('.', '..') or name.startswith('.'):
|
|
return None
|
|
if posixpath.basename(name) != name: # содержал '/' или иной разделитель
|
|
return None
|
|
if any(c in name for c in ('/', '\\', '\x00')):
|
|
return None
|
|
return name
|