Files
ACORE/apps/nextcloud_connector/views.py
T

1201 lines
47 KiB
Python

"""Виджет «Документы» аптеки — HTMX-фрагменты поверх Nextcloud WebDAV.
Каждый эндпоинт: (1) резолвит аптеку по pk, (2) проверяет can_access_pharmacy_docs,
(3) выводит путь из объекта аптеки на сервере, (4) имя файла — через safe_filename.
"""
import json
import logging
from urllib.parse import quote
from django.contrib.auth.decorators import login_required
from django.http import (
HttpResponse, HttpResponseForbidden, HttpResponseBadRequest, Http404, JsonResponse,
)
from django.shortcuts import get_object_or_404, render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from apps.locations.models import Pharmacy
from apps.nextcloud_connector.access import (
acl_section_key, additional_share_targets,
can_access_pharmacy_docs, can_edit_pharmacy_docs, pharmacy_folder_relpath,
safe_filename, get_section, safe_relpath, resolve_department,
)
from apps.nextcloud_connector.doc_acl import (
filter_entries, grant_owner_edit, rekey_grants_on_rename, resolve_access,
user_can_create_in, user_can_manage_acl, user_can_manage_path_acl,
)
from apps.nextcloud_connector.nextcloud_service import (
NextcloudConfigError, NextcloudServiceError,
)
from apps.nextcloud_connector.blank_office import empty_docx, empty_xlsx
from apps.nextcloud_connector.onlyoffice import (
OnlyOfficeConfigError,
annotate_files,
build_editor_config,
can_edit_file,
decode_file_token,
document_key,
encode_file_token,
fetch_saved_file,
get_onlyoffice_settings,
is_allowed_ds_download_url,
is_office_file,
is_previewable_file,
office_content_type,
onlyoffice_is_ready,
preview_content_type,
preview_kind,
verified_ds_payload,
)
from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService
from core.utils.htmx import render_page
logger = logging.getLogger(__name__)
MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 МБ
def _get_pharmacy_or_403(request, pk):
"""Возвращает (pharmacy, None) или (None, HttpResponseForbidden)."""
from core.location_access import accessible_pharmacies
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
def _require_pharmacy_edit(request, pharmacy):
"""None или HttpResponseForbidden, если нет права менять документы аптеки."""
if not can_edit_pharmacy_docs(request.user, pharmacy):
return HttpResponseForbidden('Нет права редактировать документы этой аптеки.')
return None
def _render_documents(request, pharmacy, message=None, message_type=None):
"""Рендер фрагмента списка документов (общий для list и upload/delete)."""
relpath = pharmacy_folder_relpath(pharmacy)
files, error = None, None
try:
dav = NextcloudWebDAVService()
files = dav.list_dir(relpath)
except NextcloudConfigError:
error = 'not_configured'
except NextcloudServiceError as e:
error = str(e)
logger.warning(f'Nextcloud docs (аптека {pharmacy.pk}): {e}')
return render(request, 'partials/pharmacy-documents.html', {
'pharmacy': pharmacy,
'files': annotate_files(files),
'error': error,
'message': message,
'message_type': message_type,
'onlyoffice_ready': onlyoffice_is_ready(),
'can_edit_docs': can_edit_pharmacy_docs(request.user, pharmacy),
})
@login_required
def pharmacy_documents(request, pk):
pharmacy, forbidden = _get_pharmacy_or_403(request, pk)
if forbidden:
return forbidden
return _render_documents(request, pharmacy)
def _file_response(content: bytes, name: str, ctype: str | None, *, inline: bool) -> HttpResponse:
"""Отдача файла: attachment (скачать) или inline (просмотрщик / iframe)."""
media = preview_content_type(name, ctype) if inline else (ctype or 'application/octet-stream')
resp = HttpResponse(content, content_type=media)
disp = 'inline' if inline else 'attachment'
resp['Content-Disposition'] = f"{disp}; filename*=UTF-8''{quote(name)}"
resp['X-Content-Type-Options'] = 'nosniff'
if inline:
# иначе XFrameOptionsMiddleware ставит DENY и PDF не открывается в модалке
resp['X-Frame-Options'] = 'SAMEORIGIN'
return resp
@login_required
def pharmacy_document_download(request, pk):
pharmacy, forbidden = _get_pharmacy_or_403(request, pk)
if forbidden:
return forbidden
name = safe_filename(request.GET.get('name'))
if not name:
return HttpResponseBadRequest('Некорректное имя файла.')
relpath = f'{pharmacy_folder_relpath(pharmacy)}/{name}'
try:
content, ctype = NextcloudWebDAVService().download(relpath)
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'Nextcloud download (аптека {pharmacy.pk}, {name}): {e}')
raise Http404('Файл недоступен.')
inline = request.GET.get('inline') == '1' and is_previewable_file(name)
return _file_response(content, name, ctype, inline=inline)
@login_required
@require_POST
def pharmacy_document_upload(request, pk):
pharmacy, forbidden = _get_pharmacy_or_403(request, pk)
if forbidden:
return forbidden
denied = _require_pharmacy_edit(request, pharmacy)
if denied:
return denied
f = request.FILES.get('file')
if not f:
return _render_documents(request, pharmacy, 'Файл не выбран.', 'error')
if f.size > MAX_UPLOAD_BYTES:
return _render_documents(request, pharmacy, 'Файл больше 100 МБ.', 'error')
name = safe_filename(f.name)
if not name:
return _render_documents(request, pharmacy, 'Недопустимое имя файла.', 'error')
relpath = f'{pharmacy_folder_relpath(pharmacy)}/{name}'
try:
NextcloudWebDAVService().upload(relpath, f.read(), f.content_type)
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'Nextcloud upload (аптека {pharmacy.pk}, {name}): {e}')
return _render_documents(request, pharmacy, 'Не удалось загрузить файл.', 'error')
return _render_documents(request, pharmacy, f'Файл «{name}» загружен.', 'success')
@login_required
@require_POST
def pharmacy_document_delete(request, pk):
pharmacy, forbidden = _get_pharmacy_or_403(request, pk)
if forbidden:
return forbidden
denied = _require_pharmacy_edit(request, pharmacy)
if denied:
return denied
name = safe_filename(request.POST.get('name'))
if not name:
return _render_documents(request, pharmacy, 'Некорректное имя файла.', 'error')
relpath = f'{pharmacy_folder_relpath(pharmacy)}/{name}'
try:
NextcloudWebDAVService().delete(relpath)
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'Nextcloud delete (аптека {pharmacy.pk}, {name}): {e}')
return _render_documents(request, pharmacy, 'Не удалось удалить файл.', 'error')
return _render_documents(request, pharmacy, f'Файл «{name}» удалён.', 'success')
@login_required
@require_POST
def pharmacy_document_mkdir(request, pk):
pharmacy, forbidden = _get_pharmacy_or_403(request, pk)
if forbidden:
return forbidden
denied = _require_pharmacy_edit(request, pharmacy)
if denied:
return denied
name = safe_filename(request.POST.get('name'))
if not name:
return _render_documents(request, pharmacy, 'Некорректное имя папки.', 'error')
try:
NextcloudWebDAVService().mkdir(f'{pharmacy_folder_relpath(pharmacy)}/{name}')
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'Nextcloud mkdir (аптека {pharmacy.pk}, {name}): {e}')
return _render_documents(request, pharmacy, 'Не удалось создать папку.', 'error')
return _render_documents(request, pharmacy, f'Папка «{name}» создана.', 'success')
@login_required
@require_POST
def pharmacy_document_rename(request, pk):
pharmacy, forbidden = _get_pharmacy_or_403(request, pk)
if forbidden:
return forbidden
denied = _require_pharmacy_edit(request, pharmacy)
if denied:
return denied
name = safe_filename(request.POST.get('name'))
new_name = safe_filename(
request.POST.get('new_name') or request.headers.get('HX-Prompt')
)
if not name or not new_name:
return _render_documents(request, pharmacy, 'Некорректное имя.', 'error')
if name == new_name:
return _render_documents(request, pharmacy)
base = pharmacy_folder_relpath(pharmacy)
try:
NextcloudWebDAVService().move(f'{base}/{name}', f'{base}/{new_name}')
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'Nextcloud rename (аптека {pharmacy.pk}, {name}): {e}')
return _render_documents(request, pharmacy, 'Не удалось переименовать.', 'error')
return _render_documents(
request, pharmacy, f'«{name}» переименован в «{new_name}».', 'success'
)
# ── Общий браузер документов (страница /documents/) ───────────────────────────
def _resolve_section_path(request, method='GET'):
"""(section, sub) или (None, HttpResponse-ошибка). sub — безопасный подпуть."""
src = request.GET if method == 'GET' else request.POST
section = get_section(request.user, src.get('section', ''))
if not section:
return None, HttpResponseForbidden('Раздел недоступен.')
sub = safe_relpath(src.get('path', ''))
if sub is None:
return None, HttpResponseBadRequest('Некорректный путь.')
if section['key'] == 'mine':
# «Мой отдел» = папка своего отдела (ACL как org) + доп. шары в корне
from apps.nextcloud_connector.services.sync_service import dept_mountpoint
dept = resolve_department(getattr(request.user, 'department_id', None))
if dept is not None:
section = {
**section,
'acl_key': 'org',
'relpath': dept_mountpoint(dept),
'department_id': dept.pk,
'department_name': dept.name,
'label': dept.name,
'show_shares': not bool(sub), # доп. доступ — только в корне вкладки
}
else:
section = {
**section,
'acl_key': 'org',
'relpath': '',
'department_id': None,
'department_name': '',
'label': 'Мой отдел',
'show_shares': True,
'shares_only': True,
}
elif section['key'] == 'org':
raw_dept = src.get('dept')
if raw_dept:
dept = resolve_department(raw_dept)
if dept is None:
return None, HttpResponseBadRequest('Відділ не знайдено.')
from apps.nextcloud_connector.services.sync_service import dept_mountpoint
section = {
**section,
'relpath': dept_mountpoint(dept),
'department_id': dept.pk,
'department_name': dept.name,
'label': dept.name,
}
else:
section = {**section, 'department_id': None}
return (section, sub), None
def _section_relpath(section, sub, name=None):
relpath = section['relpath'] + (f'/{sub}' if sub else '')
return f'{relpath}/{name}' if name else relpath
def _folder_access(user, section, sub):
return resolve_access(
user,
section_key=acl_section_key(section),
path=sub or '',
department_id=section.get('department_id'),
)
def _item_path(sub, name=None):
path = sub or ''
if name:
path = f'{path}/{name}' if path else name
return path
def _require_edit(request, section, sub, name=None):
path = _item_path(sub, name)
dec = resolve_access(
request.user,
section_key=acl_section_key(section),
path=path,
department_id=section.get('department_id'),
)
if not dec.can_edit:
return HttpResponseForbidden('Немає права редагування.')
return None
def _require_create(request, section, sub):
"""Завантаження/створення в каталозі (не плутати з edit чужого файлу)."""
if user_can_create_in(
request.user,
section_key=acl_section_key(section),
path=sub or '',
department_id=section.get('department_id'),
):
return None
return HttpResponseForbidden('Немає права створювати файли тут.')
def _require_view(request, section, sub, name=None):
path = _item_path(sub, name)
dec = resolve_access(
request.user,
section_key=acl_section_key(section),
path=path,
department_id=section.get('department_id'),
)
if not dec.can_view:
return HttpResponseForbidden('Немає права перегляду.')
return None
def _grant_creator(request, section, sub, name, *, is_folder=False):
grant_owner_edit(
request.user,
section_key=acl_section_key(section),
path=_item_path(sub, name),
department_id=section.get('department_id'),
is_folder=is_folder,
)
def _virtual_org_children(parent_id):
"""Дочірні відділи Directory як віртуальні папки в браузері."""
from core.models import Directory
qs = Directory.objects.filter(
directory_type__code='department', is_active=True, parent_id=parent_id,
).order_by('_path', 'name')
return [
{
'name': d.name,
'is_dir': True,
'is_virtual_dept': True,
'dept_id': d.pk,
'path': '',
'size': None,
'can_view': True,
'can_edit': False,
}
for d in qs
]
def _render_browser(request, section, sub, message=None, message_type=None):
files, error = None, None
folder_dec = _folder_access(request.user, section, sub)
can_create_here = False
share_entries = []
if section.get('show_shares'):
share_entries = annotate_files(additional_share_targets(request.user)) or []
# Корінь «Структура» без dept — лише дерево Directory
if section['key'] == 'org' and not section.get('department_id'):
files = annotate_files(_virtual_org_children(None))
can_edit_here = False
elif section.get('shares_only'):
# Нет своего отдела — только дополнительные шары
files = share_entries
can_edit_here = False
folder_dec = resolve_access(
request.user, section_key='org', path='', department_id=None,
)
elif not folder_dec.can_view:
error = 'access_denied'
files = []
can_edit_here = False
else:
can_create_here = user_can_create_in(
request.user,
section_key=acl_section_key(section),
path=sub or '',
department_id=section.get('department_id'),
)
# колонка дій / підсвітка: edit каталогу або право створювати (свій відділ)
can_edit_here = folder_dec.can_edit or can_create_here
# Під відділом: спочатку дочірні департаменти, потім файли WebDAV
virtual = []
if (
section['key'] in ('org', 'mine')
and section.get('department_id')
and not sub
and section['key'] == 'org'
):
# В «Мой отдел» дочерние отделы не показываем — только файлы своего узла
virtual = _virtual_org_children(section['department_id'])
try:
if not section.get('relpath'):
listed = []
else:
listed = NextcloudWebDAVService().list_dir(_section_relpath(section, sub))
for f in listed:
f['path'] = f'{sub}/{f["name"]}' if sub else f['name']
listed = filter_entries(
request.user, listed,
section_key=acl_section_key(section),
parent_path=sub or '',
department_id=section.get('department_id'),
)
files = annotate_files(virtual + listed)
except NextcloudConfigError:
error = 'not_configured'
files = annotate_files(virtual) if virtual else None
except NextcloudServiceError as e:
error = str(e)
logger.warning(f'Nextcloud docs ({section["key"]}/{sub}): {e}')
files = annotate_files(virtual) if virtual else None
crumbs, acc = [], ''
for part in (sub.split('/') if sub else []):
acc = f'{acc}/{part}' if acc else part
crumbs.append({'name': part, 'path': acc})
return render(request, 'partials/documents-browser.html', {
'section': section, 'sub': sub, 'crumbs': crumbs,
'files': files, 'error': error,
'message': message, 'message_type': message_type,
'onlyoffice_ready': onlyoffice_is_ready(),
'can_edit_here': can_edit_here,
'can_create_here': can_create_here,
'can_manage_acl': user_can_manage_path_acl(
request.user,
section_key=acl_section_key(section),
path=sub or '',
department_id=section.get('department_id'),
),
'folder_access': folder_dec.code or 'none',
'share_entries': share_entries if section.get('show_shares') and not section.get('shares_only') else [],
})
@login_required
def documents_browse(request):
resolved, err = _resolve_section_path(request)
if err:
return err
section, sub = resolved
return _render_browser(request, section, sub)
@login_required
def document_download(request):
resolved, err = _resolve_section_path(request)
if err:
return err
section, sub = resolved
name = safe_filename(request.GET.get('name'))
if not name:
return HttpResponseBadRequest('Некорректное имя файла.')
denied = _require_view(request, section, sub, name)
if denied:
return denied
try:
content, ctype = NextcloudWebDAVService().download(_section_relpath(section, sub, name))
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'Nextcloud download ({section["key"]}/{sub}/{name}): {e}')
raise Http404('Файл недоступен.')
inline = request.GET.get('inline') == '1' and is_previewable_file(name)
return _file_response(content, name, ctype, inline=inline)
@login_required
@require_POST
def document_upload(request):
resolved, err = _resolve_section_path(request, 'POST')
if err:
return err
section, sub = resolved
denied = _require_create(request, section, sub)
if denied:
return denied
f = request.FILES.get('file')
if not f:
return _render_browser(request, section, sub, 'Файл не выбран.', 'error')
if f.size > MAX_UPLOAD_BYTES:
return _render_browser(request, section, sub, 'Файл больше 100 МБ.', 'error')
name = safe_filename(f.name)
if not name:
return _render_browser(request, section, sub, 'Недопустимое имя файла.', 'error')
try:
NextcloudWebDAVService().upload(_section_relpath(section, sub, name), f.read(), f.content_type)
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'Nextcloud upload ({section["key"]}/{sub}/{name}): {e}')
return _render_browser(request, section, sub, 'Не удалось загрузить файл.', 'error')
_grant_creator(request, section, sub, name, is_folder=False)
return _render_browser(request, section, sub, f'Файл «{name}» загружен.', 'success')
@login_required
@require_POST
def document_mkdir(request):
resolved, err = _resolve_section_path(request, 'POST')
if err:
return err
section, sub = resolved
denied = _require_create(request, section, sub)
if denied:
return denied
name = safe_filename(request.POST.get('name'))
if not name:
return _render_browser(request, section, sub, 'Некорректное имя папки.', 'error')
try:
NextcloudWebDAVService().mkdir(_section_relpath(section, sub, name))
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'Nextcloud mkdir ({section["key"]}/{sub}/{name}): {e}')
return _render_browser(request, section, sub, 'Не удалось создать папку.', 'error')
_grant_creator(request, section, sub, name, is_folder=True)
return _render_browser(request, section, sub, f'Папка «{name}» создана.', 'success')
@login_required
@require_POST
def document_create(request):
"""Пустой Word/Excel, который OnlyOffice реально открывает (не «новый файл» с диска)."""
resolved, err = _resolve_section_path(request, 'POST')
if err:
return err
section, sub = resolved
denied = _require_create(request, section, sub)
if denied:
return denied
kind = (request.POST.get('type') or '').lower()
if kind not in ('folder', 'docx', 'xlsx'):
return _render_browser(request, section, sub, 'Можно создать папку, Word или Excel.', 'error')
raw_name = (request.POST.get('name') or '').strip()
if kind == 'folder':
name = safe_filename(raw_name or 'Новая папка')
if not name:
return _render_browser(request, section, sub, 'Некорректное имя папки.', 'error')
try:
NextcloudWebDAVService().mkdir(_section_relpath(section, sub, name))
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'Nextcloud mkdir ({section["key"]}/{sub}/{name}): {e}')
return _render_browser(request, section, sub, 'Не удалось создать папку.', 'error')
_grant_creator(request, section, sub, name, is_folder=True)
return _render_browser(request, section, sub, f'Папка «{name}» создана.', 'success')
if not raw_name:
raw_name = 'Новый документ' if kind == 'docx' else 'Новая таблица'
if not raw_name.lower().endswith(f'.{kind}'):
raw_name = f'{raw_name}.{kind}'
name = safe_filename(raw_name)
if not name:
return _render_browser(request, section, sub, 'Некорректное имя файла.', 'error')
payload = empty_xlsx() if kind == 'xlsx' else empty_docx()
try:
NextcloudWebDAVService().upload(
_section_relpath(section, sub, name),
payload,
office_content_type(name),
)
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'Nextcloud create ({section["key"]}/{sub}/{name}): {e}')
return _render_browser(request, section, sub, 'Не удалось создать документ.', 'error')
_grant_creator(request, section, sub, name, is_folder=False)
return _render_browser(request, section, sub, f'Файл «{name}» создан.', 'success')
@login_required
@require_POST
def document_rename(request):
resolved, err = _resolve_section_path(request, 'POST')
if err:
return err
section, sub = resolved
name = safe_filename(request.POST.get('name'))
new_name = safe_filename(
request.POST.get('new_name') or request.headers.get('HX-Prompt')
)
denied = _require_edit(request, section, sub, name)
if denied:
return denied
if not name or not new_name:
return _render_browser(request, section, sub, 'Некорректное имя.', 'error')
if name == new_name:
return _render_browser(request, section, sub)
try:
NextcloudWebDAVService().move(
_section_relpath(section, sub, name),
_section_relpath(section, sub, new_name),
)
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'Nextcloud rename ({section["key"]}/{sub}/{name}): {e}')
return _render_browser(request, section, sub, 'Не удалось переименовать.', 'error')
rekey_grants_on_rename(
section_key=acl_section_key(section),
department_id=section.get('department_id'),
old_path=_item_path(sub, name),
new_path=_item_path(sub, new_name),
)
return _render_browser(
request, section, sub, f'«{name}» переименован в «{new_name}».', 'success'
)
@login_required
@require_POST
def document_delete(request):
resolved, err = _resolve_section_path(request, 'POST')
if err:
return err
section, sub = resolved
name = safe_filename(request.POST.get('name'))
if not name:
return _render_browser(request, section, sub, 'Некорректное имя файла.', 'error')
denied = _require_edit(request, section, sub, name)
if denied:
return denied
try:
NextcloudWebDAVService().delete(_section_relpath(section, sub, name))
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'Nextcloud delete ({section["key"]}/{sub}/{name}): {e}')
return _render_browser(request, section, sub, 'Не удалось удалить.', 'error')
return _render_browser(request, section, sub, f'«{name}» удалён.', 'success')
# ── Просмотр PDF / изображений ────────────────────────────────────────────────
def _preview_payload_from_request(request):
"""Резолв PDF/картинки для встроенного просмотрщика (те же ACL, что download)."""
if request.GET.get('pharmacy'):
raw = request.GET.get('pharmacy', '')
if not raw.isdigit():
return None, HttpResponseBadRequest('Некорректная аптека.')
pharmacy, forbidden = _get_pharmacy_or_403(request, int(raw))
if forbidden:
return None, forbidden
name = safe_filename(request.GET.get('name'))
if not name:
return None, HttpResponseBadRequest('Некорректное имя файла.')
kind = preview_kind(name)
if not kind:
return None, HttpResponseBadRequest('Этот тип файла нельзя открыть в просмотрщике.')
return {
'filename': name,
'kind': kind,
'content_url': (
f'/pharmacies/{pharmacy.pk}/documents/download/'
f'?name={quote(name)}&inline=1'
),
'download_url': (
f'/pharmacies/{pharmacy.pk}/documents/download/'
f'?name={quote(name)}'
),
'back': {
'kind': 'pharmacy',
'pharmacy_id': pharmacy.pk,
'label': 'Документы аптеки',
},
}, None
resolved, err = _resolve_section_path(request)
if err:
return None, err
section, sub = resolved
name = safe_filename(request.GET.get('name'))
if not name:
return None, HttpResponseBadRequest('Некорректное имя файла.')
kind = preview_kind(name)
if not kind:
return None, HttpResponseBadRequest('Этот тип файла нельзя открыть в просмотрщике.')
denied = _require_view(request, section, sub, name)
if denied:
return None, denied
q = [
f'section={quote(section["key"])}',
f'path={quote(sub)}',
f'name={quote(name)}',
]
if section.get('department_id'):
q.append(f'dept={section["department_id"]}')
qs = '&'.join(q)
return {
'filename': name,
'kind': kind,
'content_url': f'/documents/download/?{qs}&inline=1',
'download_url': f'/documents/download/?{qs}',
'back': {
'kind': 'section',
'section': section['key'],
'path': sub,
'dept': section.get('department_id'),
'label': section['label'],
},
}, None
@login_required
def document_preview_view(request):
"""Встроенный просмотр PDF и изображений (браузерный, без OnlyOffice)."""
resolved, err = _preview_payload_from_request(request)
if err:
return err
return render_page(request, 'documents-preview.html', {
'filename': resolved['filename'],
'preview_kind': resolved['kind'],
'content_url': resolved['content_url'],
'download_url': resolved['download_url'],
'back': resolved['back'],
})
@login_required
def pharmacy_document_preview(request, pk):
"""Просмотр PDF/картинки аптеки — URL под /pharmacies/ (без гейта раздела documents)."""
from django.http import QueryDict
q = request.GET.copy()
q['pharmacy'] = str(pk)
request.GET = q
return document_preview_view(request)
# ── OnlyOffice ────────────────────────────────────────────────────────────────
def _office_payload_from_request(request):
"""Резолв файла для редактора из query (раздел или аптека)."""
if request.GET.get('report_doc'):
from apps.reports.documents import (
report_document_relpath,
user_can_edit_report_document,
user_can_view_report_document,
)
from apps.reports.models import ReportDocument
raw = request.GET.get('report_doc', '')
if not raw.isdigit():
return None, HttpResponseBadRequest('Некорректный документ.')
doc = get_object_or_404(ReportDocument, pk=int(raw))
if not user_can_view_report_document(request.user, doc):
return None, HttpResponseForbidden()
name = doc.filename
if not is_office_file(name):
return None, HttpResponseBadRequest('Этот тип файла OnlyOffice не открывает.')
return {
'filename': name,
'relpath': report_document_relpath(doc),
'writable': user_can_edit_report_document(request.user, doc) and can_edit_file(name),
'back': {'kind': 'report_doc', 'label': 'Отчёты'},
'token_payload': {
'kind': 'report_doc',
'doc_id': doc.pk,
'name': name,
'uid': request.user.pk,
},
}, None
if request.GET.get('pharmacy'):
raw = request.GET.get('pharmacy', '')
if not raw.isdigit():
return None, HttpResponseBadRequest('Некорректная аптека.')
pharmacy, forbidden = _get_pharmacy_or_403(request, int(raw))
if forbidden:
return None, forbidden
name = safe_filename(request.GET.get('name'))
if not name:
return None, HttpResponseBadRequest('Некорректное имя файла.')
if not is_office_file(name):
return None, HttpResponseBadRequest('Этот тип файла OnlyOffice не открывает.')
relpath = f'{pharmacy_folder_relpath(pharmacy)}/{name}'
writable = can_edit_pharmacy_docs(request.user, pharmacy) and can_edit_file(name)
back = {
'kind': 'pharmacy',
'pharmacy_id': pharmacy.pk,
'label': 'Документы аптеки',
}
token_payload = {
'kind': 'pharmacy',
'pharmacy_id': pharmacy.pk,
'name': name,
'uid': request.user.pk,
}
return {
'filename': name,
'relpath': relpath,
'writable': writable,
'back': back,
'token_payload': token_payload,
}, None
resolved, err = _resolve_section_path(request)
if err:
return None, err
section, sub = resolved
name = safe_filename(request.GET.get('name'))
if not name:
return None, HttpResponseBadRequest('Некорректное имя файла.')
if not is_office_file(name):
return None, HttpResponseBadRequest('Этот тип файла OnlyOffice не открывает.')
denied = _require_view(request, section, sub, name)
if denied:
return None, denied
relpath = _section_relpath(section, sub, name)
acl_key = acl_section_key(section)
can_write = (
resolve_access(
request.user,
section_key=acl_key,
path=f'{sub}/{name}' if sub else name,
department_id=section.get('department_id'),
).can_edit
and can_edit_file(name)
)
# В токене — реальный раздел хранилища (mine → org), чтобы callback резолвился
token_section = acl_key if section['key'] == 'mine' else section['key']
token_payload = {
'kind': 'section',
'section': token_section,
'path': sub,
'dept': section.get('department_id'),
'name': name,
'uid': request.user.pk,
}
return {
'filename': name,
'relpath': relpath,
'writable': can_write,
'back': {
'kind': 'section',
'section': section['key'],
'path': sub,
'dept': section.get('department_id'),
'label': section['label'],
},
'token_payload': token_payload,
}, None
def _resolve_token_relpath(payload: dict) -> tuple[str, str, bool] | None:
"""(relpath, filename, writable) или None."""
kind = payload.get('kind')
name = safe_filename(payload.get('name'))
if not name or not is_office_file(name):
return None
if kind == 'pharmacy':
from django.contrib.auth import get_user_model
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()
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)
return f'{pharmacy_folder_relpath(pharmacy)}/{name}', name, writable
if kind == 'report_doc':
from apps.reports.documents import (
report_document_relpath,
user_can_edit_report_document,
user_can_view_report_document,
)
from apps.reports.models import ReportDocument
from django.contrib.auth import get_user_model
user = get_user_model().objects.filter(pk=payload.get('uid')).first()
doc = ReportDocument.objects.filter(pk=payload.get('doc_id')).first()
if user is None or doc is None or not user_can_view_report_document(user, doc):
return None
if doc.filename != name:
return None
writable = user_can_edit_report_document(user, doc) and can_edit_file(name)
return report_document_relpath(doc), name, writable
if kind == 'section':
from django.contrib.auth import get_user_model
from apps.nextcloud_connector.services.sync_service import dept_mountpoint
user = get_user_model().objects.filter(pk=payload.get('uid')).first()
if user is None:
return None
section = get_section(user, payload.get('section', ''))
if not section:
return None
sub = safe_relpath(payload.get('path', ''))
if sub is None:
return None
dept_id = payload.get('dept')
if section['key'] in ('org', 'mine') and dept_id:
dept = resolve_department(dept_id)
if dept is None:
return None
section = {
**section,
'acl_key': 'org' if section['key'] == 'mine' else section.get('acl_key'),
'relpath': dept_mountpoint(dept),
'department_id': dept.pk,
}
path = f'{sub}/{name}' if sub else name
dec = resolve_access(
user, section_key=acl_section_key(section), path=path,
department_id=section.get('department_id'),
)
if not dec.can_view:
return None
return (
_section_relpath(section, sub, name),
name,
dec.can_edit and can_edit_file(name),
)
return None
# ── ACL: видача прав на папку/файл ────────────────────────────────────────────
def _acl_panel_context(request, section, sub, name):
from apps.nextcloud_connector.access import people_picker_payload
from apps.nextcloud_connector.models import DocumentAccessGrant
from django.contrib.auth.models import Group
path = f'{sub}/{name}' if name and sub else (name or sub or '')
grants = DocumentAccessGrant.objects.filter(
section_key=acl_section_key(section),
department_id=section.get('department_id'),
path=path,
is_active=True,
).select_related('employee', 'grant_department', 'group')
ctx = {
'section': section,
'sub': sub,
'path': path,
'name': name or '',
'is_folder': not bool(name),
'grants': grants,
'empty_ids': set(),
'group_options': list(Group.objects.order_by('name').values('pk', 'name')),
}
ctx.update(people_picker_payload())
return ctx
def _require_acl_manage(request, section, sub, name=None):
path = _item_path(sub, name)
if user_can_manage_path_acl(
request.user,
section_key=acl_section_key(section),
path=path,
department_id=section.get('department_id'),
):
return None
return HttpResponseForbidden('Немає права керувати доступом.')
@login_required
def document_acl_panel(request):
"""Фрагмент зі списком прав + формою (staff / автор / керівник з edit)."""
resolved, err = _resolve_section_path(request)
if err:
return err
section, sub = resolved
name = safe_filename(request.GET.get('name')) if request.GET.get('name') else None
denied = _require_acl_manage(request, section, sub, name)
if denied:
return denied
return render(
request, 'partials/documents-acl.html',
_acl_panel_context(request, section, sub, name),
)
@login_required
@require_POST
def document_acl_grant(request):
resolved, err = _resolve_section_path(request, 'POST')
if err:
return err
section, sub = resolved
name = safe_filename(request.POST.get('name')) if request.POST.get('name') else None
denied = _require_acl_manage(request, section, sub, name)
if denied:
return denied
path = f'{sub}/{name}' if name and sub else (name or sub or '')
level = request.POST.get('level') or 'view'
if level not in ('view', 'edit'):
level = 'view'
from apps.nextcloud_connector.models import DocumentAccessGrant
from django.contrib.auth.models import Group
from django.contrib.auth import get_user_model
note = (request.POST.get('note') or '')[:255]
base = dict(
section_key=acl_section_key(section),
department_id=section.get('department_id'),
path=path,
is_folder=not bool(name),
level=level,
note=note,
)
subject = request.POST.get('subject_type') or 'employee'
created = 0
if subject == 'employee':
raw_ids = request.POST.getlist('employee_ids') or []
if not raw_ids and request.POST.get('employee_id'):
raw_ids = [request.POST.get('employee_id')]
employees = list(
get_user_model().objects.filter(pk__in=raw_ids, is_active=True)
)
if not employees:
return HttpResponseBadRequest('Оберіть хоча б одного співробітника.')
for emp in employees:
g = DocumentAccessGrant(employee=emp, **base)
g.full_clean()
g.save()
created += 1
elif subject == 'department':
dept = resolve_department(request.POST.get('grant_department_id'))
if not dept:
return HttpResponseBadRequest('Відділ не знайдено.')
g = DocumentAccessGrant(grant_department=dept, **base)
g.full_clean()
g.save()
created = 1
elif subject == 'group':
group = Group.objects.filter(pk=request.POST.get('group_id')).first()
if not group:
return HttpResponseBadRequest('Групу не знайдено.')
g = DocumentAccessGrant(group=group, **base)
g.full_clean()
g.save()
created = 1
else:
return HttpResponseBadRequest('Невірний тип отримувача.')
if created < 1:
return HttpResponseBadRequest('Нічого не видано.')
from django.http import QueryDict
q = QueryDict(mutable=True)
q['section'] = section['key']
q['path'] = sub or ''
if section.get('department_id'):
q['dept'] = str(section['department_id'])
if name:
q['name'] = name
request.GET = q
return document_acl_panel(request)
@login_required
@require_POST
def document_acl_revoke(request):
from apps.nextcloud_connector.models import DocumentAccessGrant
from django.http import QueryDict
grant = DocumentAccessGrant.objects.filter(pk=request.POST.get('grant_id')).first()
if not grant:
return HttpResponseBadRequest('Право не знайдено.')
if not user_can_manage_path_acl(
request.user,
section_key=grant.section_key,
path=grant.path or '',
department_id=grant.department_id,
):
return HttpResponseForbidden('Немає права керувати доступом.')
grant.is_active = False
grant.save(update_fields=['is_active', 'updated_at'])
# після revoke показати панель для того ж шляху (з POST форми)
q = QueryDict(mutable=True)
q['section'] = request.POST.get('section') or grant.section_key
q['path'] = request.POST.get('path') or ''
if request.POST.get('dept') or grant.department_id:
q['dept'] = request.POST.get('dept') or str(grant.department_id)
if request.POST.get('name'):
q['name'] = request.POST.get('name')
request.GET = q
return document_acl_panel(request)
@login_required
def document_editor_view(request):
try:
settings = get_onlyoffice_settings()
except OnlyOfficeConfigError as exc:
return HttpResponseBadRequest(str(exc))
if not settings:
return HttpResponseBadRequest('OnlyOffice пока не настроен.')
resolved, err = _office_payload_from_request(request)
if err:
return err
mode = 'edit' if resolved['writable'] else 'view'
token = encode_file_token({**resolved['token_payload'], 'mode': mode})
size, modified = None, None
try:
meta = NextcloudWebDAVService().stat(resolved['relpath'])
if meta:
size, modified = meta.get('size'), meta.get('modified')
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'OnlyOffice stat ({resolved["relpath"]}): {e}')
key = document_key(resolved['relpath'], size=size, modified=modified)
config = build_editor_config(
request=request,
settings=settings,
filename=resolved['filename'],
relpath=resolved['relpath'],
token=token,
mode=mode,
key=key,
user=request.user,
)
return render_page(request, 'documents-editor.html', {
'editor_config': json.dumps(config, ensure_ascii=False),
'api_js': settings['api_js'],
'filename': resolved['filename'],
'mode': mode,
'back': resolved['back'],
})
@csrf_exempt
def onlyoffice_content_view(request, token):
try:
settings = get_onlyoffice_settings()
except OnlyOfficeConfigError:
return HttpResponseForbidden()
if not settings:
return HttpResponseForbidden()
if verified_ds_payload(request, settings) is None:
return HttpResponseForbidden('Нужна подпись Document Server.')
try:
payload = decode_file_token(token)
except Exception:
return HttpResponseForbidden('Сессия редактора истекла.')
resolved = _resolve_token_relpath(payload)
if not resolved:
return HttpResponseForbidden()
relpath, name, _writable = resolved
try:
content, ctype = NextcloudWebDAVService().download(relpath)
except (NextcloudConfigError, NextcloudServiceError) as e:
logger.warning(f'OnlyOffice content ({relpath}): {e}')
raise Http404('Файл недоступен.')
resp = HttpResponse(content, content_type=office_content_type(name, ctype))
resp['Content-Disposition'] = f'attachment; filename*=UTF-8\'\'{quote(name)}'
resp['Content-Length'] = str(len(content))
return resp
@csrf_exempt
@require_POST
def onlyoffice_callback_view(request, token):
try:
settings = get_onlyoffice_settings()
except OnlyOfficeConfigError:
return JsonResponse({'error': 1})
if not settings:
return JsonResponse({'error': 1})
ds_payload = verified_ds_payload(request, settings)
if ds_payload is None:
return JsonResponse({'error': 1})
try:
file_token = decode_file_token(token)
except Exception:
return JsonResponse({'error': 1})
resolved = _resolve_token_relpath(file_token)
if not resolved:
return JsonResponse({'error': 1})
relpath, _name, writable = resolved
status = ds_payload.get('status')
# 2 — сохранить и закрыть, 6 — принудительное сохранение
if status not in (2, 6):
return JsonResponse({'error': 0})
if not writable:
return JsonResponse({'error': 1})
file_url = ds_payload.get('url')
if not file_url or not is_allowed_ds_download_url(file_url, settings):
return JsonResponse({'error': 1})
try:
content = fetch_saved_file(file_url, settings)
NextcloudWebDAVService().upload(relpath, content)
except Exception as e:
logger.warning(f'OnlyOffice callback save ({relpath}): {e}')
return JsonResponse({'error': 1})
return JsonResponse({'error': 0})