Nextcloud connector: виджет «Документы» аптеки (Фаза 3)
- WebDAV-клиент (PROPFIND/GET/PUT/DELETE) под сервисным аккаунтом - access.py: can_access_pharmacy_docs (аптека видит только свою) + safe_filename (защита от path traversal); путь папки выводится только из объекта аптеки - Вьюхи documents/download/upload/delete с проверкой доступа на каждом эндпоинте - Вкладка «Документы» на pharmacy-detail с ленивой HTMX-загрузкой - Тесты авторизации: свой/чужой скоуп, 403, traversal, аноним Аптечные сотрудники учёток Nextcloud не имеют — доступ идёт через портал под сервисным аккаунтом, вся авторизация на стороне ACORE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ urlpatterns = [
|
||||
path('', include('apps.it_manager.urls')),
|
||||
path('', include('apps.mattermost_connector.urls')),
|
||||
path('', include('apps.utilities.urls')),
|
||||
path('', include('apps.nextcloud_connector.urls')),
|
||||
path('tinymce/', include('tinymce.urls')),
|
||||
path('', views.index_view, name='index'),
|
||||
path('accounts/login/', views.login_view, name='login'),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Авторизация доступа к документам аптек.
|
||||
|
||||
Ключевой инвариант безопасности: путь к папке аптеки выводится ТОЛЬКО из
|
||||
объекта Pharmacy на сервере (никогда из данных клиента), а имя файла проходит
|
||||
safe_filename() — так исключён обход каталога (path traversal) и переход в чужую
|
||||
аптеку. Сервисный аккаунт технически видит все папки аптек, поэтому весь скоуп
|
||||
держится здесь, на стороне портала.
|
||||
"""
|
||||
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:
|
||||
"""Кто вправе видеть/трогать документы конкретной аптеки.
|
||||
|
||||
- офис/IT/руководство (is_staff) — все аптеки (позже сузить пермишеном/регионом);
|
||||
- линейный персонал аптеки — ТОЛЬКО своя аптека (user_pharmacy);
|
||||
- все остальные — нет.
|
||||
"""
|
||||
if not getattr(user, 'is_authenticated', False):
|
||||
return False
|
||||
if user.is_superuser or user.is_staff:
|
||||
return True
|
||||
return (
|
||||
getattr(user, 'user_location_type', None) == 'pharmacy'
|
||||
and getattr(user, 'user_pharmacy_id', None) == pharmacy.pk
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -71,3 +71,97 @@ class EnsureFolderTests(TestCase):
|
||||
self.assertEqual(fid, 7)
|
||||
self.assertNotIn(('create', '03_Отделы/HR'), svc.calls)
|
||||
self.assertIn(('perm', 7, 'nc-dept-hr', 15), svc.calls)
|
||||
|
||||
|
||||
import datetime
|
||||
from django.test import Client
|
||||
from core.models import Directory, DirectoryType
|
||||
from apps.locations.models import Pharmacy
|
||||
from apps.employees.models import Employee
|
||||
from apps.nextcloud_connector.access import (
|
||||
can_access_pharmacy_docs, safe_filename,
|
||||
)
|
||||
|
||||
|
||||
class SafeFilenameTests(TestCase):
|
||||
def test_accepts_plain_names(self):
|
||||
for n in ('licence.pdf', 'Договор 2026.docx', 'фото-1.jpg'):
|
||||
self.assertEqual(safe_filename(n), n)
|
||||
|
||||
def test_rejects_traversal_and_separators(self):
|
||||
for n in ('../secret', 'a/b.txt', 'a\\b.txt', '..', '.', '', None,
|
||||
'.hidden', 'x\x00y'):
|
||||
self.assertIsNone(safe_filename(n), f'должно отклонить: {n!r}')
|
||||
|
||||
|
||||
class AccessControlTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
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=17, location_type='pharmacy')
|
||||
cls.ph_b = Pharmacy.objects.create(organization=org, location_region=region,
|
||||
pharmacy_number=18, location_type='pharmacy')
|
||||
common = dict(mattermost_sync_enabled=False, ad_sync_enabled=False,
|
||||
hire_date=datetime.date(2020, 1, 1))
|
||||
cls.emp_a = Employee.objects.create_user(
|
||||
username='ph_a_emp', user_location_type='pharmacy', user_pharmacy=cls.ph_a, **common)
|
||||
cls.emp_b = Employee.objects.create_user(
|
||||
username='ph_b_emp', user_location_type='pharmacy', user_pharmacy=cls.ph_b, **common)
|
||||
cls.staff = Employee.objects.create_user(username='office', is_staff=True, **common)
|
||||
|
||||
def test_pharmacy_employee_sees_only_own(self):
|
||||
self.assertTrue(can_access_pharmacy_docs(self.emp_a, self.ph_a))
|
||||
self.assertFalse(can_access_pharmacy_docs(self.emp_a, self.ph_b))
|
||||
|
||||
def test_staff_sees_all(self):
|
||||
self.assertTrue(can_access_pharmacy_docs(self.staff, self.ph_a))
|
||||
self.assertTrue(can_access_pharmacy_docs(self.staff, self.ph_b))
|
||||
|
||||
|
||||
class DocumentsViewTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
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=17, location_type='pharmacy')
|
||||
cls.ph_b = Pharmacy.objects.create(organization=org, location_region=region,
|
||||
pharmacy_number=18, location_type='pharmacy')
|
||||
common = dict(mattermost_sync_enabled=False, ad_sync_enabled=False,
|
||||
hire_date=datetime.date(2020, 1, 1))
|
||||
cls.emp_a = Employee.objects.create_user(
|
||||
username='ph_a_emp', user_location_type='pharmacy', user_pharmacy=cls.ph_a, **common)
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
|
||||
def test_own_pharmacy_ok_but_storage_not_configured(self):
|
||||
self.client.force_login(self.emp_a)
|
||||
r = self.client.get(f'/pharmacies/{self.ph_a.pk}/documents/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertContains(r, 'не настроен') # коннектор не сконфигурирован в тестах
|
||||
|
||||
def test_other_pharmacy_forbidden(self):
|
||||
self.client.force_login(self.emp_a)
|
||||
r = self.client.get(f'/pharmacies/{self.ph_b.pk}/documents/')
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_anonymous_redirected(self):
|
||||
r = self.client.get(f'/pharmacies/{self.ph_a.pk}/documents/')
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
def test_download_rejects_bad_filename(self):
|
||||
self.client.force_login(self.emp_a)
|
||||
r = self.client.get(f'/pharmacies/{self.ph_a.pk}/documents/download/?name=../etc/passwd')
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_download_other_pharmacy_forbidden(self):
|
||||
self.client.force_login(self.emp_a)
|
||||
r = self.client.get(f'/pharmacies/{self.ph_b.pk}/documents/download/?name=x.pdf')
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('pharmacies/<int:pk>/documents/',
|
||||
views.pharmacy_documents, name='pharmacy_documents'),
|
||||
path('pharmacies/<int:pk>/documents/download/',
|
||||
views.pharmacy_document_download, name='pharmacy_document_download'),
|
||||
path('pharmacies/<int:pk>/documents/upload/',
|
||||
views.pharmacy_document_upload, name='pharmacy_document_upload'),
|
||||
path('pharmacies/<int:pk>/documents/delete/',
|
||||
views.pharmacy_document_delete, name='pharmacy_document_delete'),
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Виджет «Документы» аптеки — HTMX-фрагменты поверх Nextcloud WebDAV.
|
||||
|
||||
Каждый эндпоинт: (1) резолвит аптеку по pk, (2) проверяет can_access_pharmacy_docs,
|
||||
(3) выводит путь из объекта аптеки на сервере, (4) имя файла — через safe_filename.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import (
|
||||
HttpResponse, HttpResponseForbidden, HttpResponseBadRequest, Http404,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from apps.locations.models import Pharmacy
|
||||
from apps.nextcloud_connector.access import (
|
||||
can_access_pharmacy_docs, pharmacy_folder_relpath, safe_filename,
|
||||
)
|
||||
from apps.nextcloud_connector.nextcloud_service import (
|
||||
NextcloudConfigError, NextcloudServiceError,
|
||||
)
|
||||
from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 МБ
|
||||
|
||||
|
||||
def _get_pharmacy_or_403(request, pk):
|
||||
"""Возвращает (pharmacy, None) или (None, HttpResponseForbidden)."""
|
||||
pharmacy = get_object_or_404(Pharmacy, pk=pk)
|
||||
if not can_access_pharmacy_docs(request.user, pharmacy):
|
||||
return None, HttpResponseForbidden('Нет доступа к документам этой аптеки.')
|
||||
return pharmacy, 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': files,
|
||||
'error': error,
|
||||
'message': message,
|
||||
'message_type': message_type,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def pharmacy_documents(request, pk):
|
||||
pharmacy, forbidden = _get_pharmacy_or_403(request, pk)
|
||||
if forbidden:
|
||||
return forbidden
|
||||
return _render_documents(request, pharmacy)
|
||||
|
||||
|
||||
@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('Файл недоступен.')
|
||||
resp = HttpResponse(content, content_type=ctype)
|
||||
resp['Content-Disposition'] = f'attachment; filename*=UTF-8\'\'{name}'
|
||||
return resp
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def pharmacy_document_upload(request, pk):
|
||||
pharmacy, forbidden = _get_pharmacy_or_403(request, pk)
|
||||
if forbidden:
|
||||
return forbidden
|
||||
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
|
||||
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')
|
||||
@@ -0,0 +1,139 @@
|
||||
"""WebDAV-клиент Nextcloud для файловых операций в папках аптек.
|
||||
|
||||
Работает под сервисным аккаунтом (ConnectorConfig(nextcloud)). Аптечные
|
||||
сотрудники собственных учёток Nextcloud не имеют — доступ к документам идёт
|
||||
через портал, а портал ходит в Nextcloud этим сервисным аккаунтом. Group Folder
|
||||
аптеки виден в DAV-дереве сервисного аккаунта по своей точке монтирования.
|
||||
|
||||
ВАЖНО: относительные пути формирует ТОЛЬКО портал из данных аптеки. Имя файла,
|
||||
пришедшее от пользователя, обязано пройти access.safe_filename() до попадания сюда.
|
||||
"""
|
||||
import logging
|
||||
from urllib.parse import quote, unquote
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import httpx
|
||||
|
||||
from apps.nextcloud_connector.nextcloud_service import (
|
||||
NextcloudConfigError, NextcloudServiceError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DAV_NS = '{DAV:}'
|
||||
|
||||
PROPFIND_BODY = (
|
||||
'<?xml version="1.0"?>'
|
||||
'<d:propfind xmlns:d="DAV:"><d:prop>'
|
||||
'<d:displayname/><d:getcontentlength/><d:getlastmodified/>'
|
||||
'<d:getcontenttype/><d:resourcetype/>'
|
||||
'</d:prop></d:propfind>'
|
||||
)
|
||||
|
||||
|
||||
class NextcloudWebDAVService:
|
||||
def __init__(self):
|
||||
from core.models import ConnectorConfig
|
||||
cfg = ConnectorConfig.objects.filter(connector_type='nextcloud').first()
|
||||
if not cfg:
|
||||
raise NextcloudConfigError('Настройки коннектора Nextcloud не найдены.')
|
||||
if not cfg.is_enabled:
|
||||
raise NextcloudConfigError('Коннектор Nextcloud отключён.')
|
||||
secret = cfg.token or cfg.password
|
||||
if not cfg.host or not cfg.username or not secret:
|
||||
raise NextcloudConfigError(
|
||||
'Для коннектора Nextcloud нужны host, username и token сервисного аккаунта.'
|
||||
)
|
||||
|
||||
extra = cfg.extra_config or {}
|
||||
scheme = 'https' if cfg.use_ssl else 'http'
|
||||
port = f':{cfg.port}' if cfg.port else ''
|
||||
base = f'{scheme}://{cfg.host}{port}'
|
||||
self.username = cfg.username
|
||||
self.dav_root = f'{base}/remote.php/dav/files/{quote(cfg.username)}'
|
||||
|
||||
verify = bool(extra.get('verify_ssl', True))
|
||||
self._client = httpx.Client(
|
||||
auth=(cfg.username, secret),
|
||||
verify=verify,
|
||||
timeout=extra.get('timeout', 30),
|
||||
)
|
||||
|
||||
def _url(self, relpath: str) -> str:
|
||||
return f'{self.dav_root}/{quote(relpath, safe="/")}'
|
||||
|
||||
def _raise(self, resp, relpath):
|
||||
raise NextcloudServiceError(
|
||||
f'WebDAV {resp.request.method} «{relpath}»: HTTP {resp.status_code}'
|
||||
)
|
||||
|
||||
# --- операции ---
|
||||
|
||||
def list_dir(self, relpath: str) -> list[dict]:
|
||||
"""Содержимое папки (глубина 1). Сама папка из результата исключается."""
|
||||
url = self._url(relpath)
|
||||
try:
|
||||
resp = self._client.request(
|
||||
'PROPFIND', url, content=PROPFIND_BODY,
|
||||
headers={'Depth': '1', 'Content-Type': 'application/xml'},
|
||||
)
|
||||
except httpx.HTTPError as e:
|
||||
raise NextcloudServiceError(f'WebDAV PROPFIND «{relpath}»: {e}') from e
|
||||
if resp.status_code == 404:
|
||||
return []
|
||||
if resp.status_code != 207:
|
||||
self._raise(resp, relpath)
|
||||
|
||||
self_href = unquote(url.split('/remote.php')[1]).rstrip('/')
|
||||
entries: list[dict] = []
|
||||
root = ET.fromstring(resp.content)
|
||||
for r in root.findall(f'{DAV_NS}response'):
|
||||
href_el = r.find(f'{DAV_NS}href')
|
||||
if href_el is None or not href_el.text:
|
||||
continue
|
||||
href = unquote(href_el.text)
|
||||
if href.rstrip('/').endswith(self_href):
|
||||
continue # это сама папка, не её содержимое
|
||||
prop = r.find(f'{DAV_NS}propstat/{DAV_NS}prop')
|
||||
is_dir = prop is not None and prop.find(
|
||||
f'{DAV_NS}resourcetype/{DAV_NS}collection') is not None
|
||||
name = unquote(href.rstrip('/')).rsplit('/', 1)[-1]
|
||||
size_el = prop.find(f'{DAV_NS}getcontentlength') if prop is not None else None
|
||||
mod_el = prop.find(f'{DAV_NS}getlastmodified') if prop is not None else None
|
||||
entries.append({
|
||||
'name': name,
|
||||
'is_dir': is_dir,
|
||||
'size': int(size_el.text) if (size_el is not None and size_el.text) else None,
|
||||
'modified': mod_el.text if mod_el is not None else None,
|
||||
})
|
||||
entries.sort(key=lambda e: (not e['is_dir'], e['name'].lower()))
|
||||
return entries
|
||||
|
||||
def download(self, relpath: str) -> tuple[bytes, str]:
|
||||
try:
|
||||
resp = self._client.get(self._url(relpath))
|
||||
except httpx.HTTPError as e:
|
||||
raise NextcloudServiceError(f'WebDAV GET «{relpath}»: {e}') from e
|
||||
if resp.status_code != 200:
|
||||
self._raise(resp, relpath)
|
||||
return resp.content, resp.headers.get('content-type', 'application/octet-stream')
|
||||
|
||||
def upload(self, relpath: str, content: bytes, content_type: str | None = None) -> None:
|
||||
headers = {'Content-Type': content_type} if content_type else {}
|
||||
try:
|
||||
resp = self._client.put(self._url(relpath), content=content, headers=headers)
|
||||
except httpx.HTTPError as e:
|
||||
raise NextcloudServiceError(f'WebDAV PUT «{relpath}»: {e}') from e
|
||||
if resp.status_code not in (200, 201, 204):
|
||||
self._raise(resp, relpath)
|
||||
|
||||
def delete(self, relpath: str) -> None:
|
||||
try:
|
||||
resp = self._client.delete(self._url(relpath))
|
||||
except httpx.HTTPError as e:
|
||||
raise NextcloudServiceError(f'WebDAV DELETE «{relpath}»: {e}') from e
|
||||
if resp.status_code not in (200, 204, 404):
|
||||
self._raise(resp, relpath)
|
||||
|
||||
def close(self):
|
||||
self._client.close()
|
||||
@@ -71,6 +71,8 @@
|
||||
style="padding:0.375rem 1rem; font-size:0.8125rem; font-weight:500; border:none; border-radius:calc(var(--radius) - 2px); cursor:pointer; background:transparent; color:var(--color-muted-foreground);">{% trans "Интернет-провайдер" %}</button>
|
||||
<button type="button" class="ph-tab-btn" data-tab="utilities" onclick="phTabSwitch('utilities')"
|
||||
style="padding:0.375rem 1rem; font-size:0.8125rem; font-weight:500; border:none; border-radius:calc(var(--radius) - 2px); cursor:pointer; background:transparent; color:var(--color-muted-foreground);">{% trans "Коммунальный отдел" %}</button>
|
||||
<button type="button" class="ph-tab-btn" data-tab="documents" onclick="phTabSwitch('documents')"
|
||||
style="padding:0.375rem 1rem; font-size:0.8125rem; font-weight:500; border:none; border-radius:calc(var(--radius) - 2px); cursor:pointer; background:transparent; color:var(--color-muted-foreground);">{% trans "Документы" %}</button>
|
||||
</div>
|
||||
|
||||
<!-- ══ ВКЛАДКА: ОСНОВНА ІНФОРМАЦІЯ ══ -->
|
||||
@@ -328,6 +330,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ ВКЛАДКА: ДОКУМЕНТИ (ленива загрузка через WebDAV) ══ -->
|
||||
<div id="ph-panel-documents" class="ph-panel" style="display:none;">
|
||||
<div id="ph-documents-slot" data-url="/pharmacies/{{ pharmacy.pk }}/documents/"
|
||||
style="font-size:0.8125rem; color:var(--color-muted-foreground); padding:0.875rem;">
|
||||
{% trans "Загрузка…" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -342,5 +352,13 @@
|
||||
btn.style.color = active ? 'var(--color-card-foreground)' : 'var(--color-muted-foreground)';
|
||||
btn.style.boxShadow = active ? '0 1px 2px rgb(0 0 0/0.08)' : '';
|
||||
});
|
||||
// Документы грузим лениво — только при первом открытии вкладки.
|
||||
if (tab === 'documents') {
|
||||
var slot = document.getElementById('ph-documents-slot');
|
||||
if (slot && !slot.dataset.loaded) {
|
||||
slot.dataset.loaded = '1';
|
||||
htmx.ajax('GET', slot.getAttribute('data-url'), { target: '#ph-documents-slot', swap: 'innerHTML' });
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{% load i18n %}
|
||||
<div style="background:var(--color-card); border:1px solid var(--color-border); border-radius:0.75rem; padding:1.25rem;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.875rem;">
|
||||
<h2 style="font-size:1rem; font-weight:600; color:var(--color-foreground);">{% trans "Документы аптеки" %}</h2>
|
||||
<span style="font-size:0.75rem; color:var(--color-muted-foreground);">Nextcloud</span>
|
||||
</div>
|
||||
|
||||
{% if message %}
|
||||
<div style="margin-bottom:0.875rem; padding:0.625rem 0.875rem; border-radius:0.5rem; font-size:0.8125rem;
|
||||
{% if message_type == 'success' %}color:#137333; background:rgba(19,115,51,0.1); border:1px solid rgba(19,115,51,0.25);
|
||||
{% else %}color:#d63333; background:rgba(214,51,51,0.1); border:1px solid rgba(214,51,51,0.25);{% endif %}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error == 'not_configured' %}
|
||||
<div style="padding:0.875rem; font-size:0.8125rem; color:var(--color-muted-foreground);">
|
||||
{% trans "Файловое хранилище пока не настроено." %}
|
||||
</div>
|
||||
{% elif error %}
|
||||
<div style="padding:0.875rem; font-size:0.8125rem; color:#d63333;">
|
||||
{% trans "Не удалось получить список документов." %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if files %}
|
||||
<div style="border:1px solid var(--color-border); border-radius:0.5rem; overflow:hidden; margin-bottom:0.875rem;">
|
||||
<table style="width:100%; border-collapse:collapse; font-size:0.8125rem;">
|
||||
<tbody>
|
||||
{% for f in files %}
|
||||
<tr style="border-bottom:1px solid var(--color-border);">
|
||||
<td style="padding:0.5rem 0.75rem;">
|
||||
{% if f.is_dir %}📁{% else %}📄{% endif %}
|
||||
{% if f.is_dir %}
|
||||
<span style="color:var(--color-foreground);">{{ f.name }}</span>
|
||||
{% else %}
|
||||
<a href="/pharmacies/{{ pharmacy.pk }}/documents/download/?name={{ f.name|urlencode }}"
|
||||
style="color:var(--color-primary); text-decoration:none;">{{ f.name }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding:0.5rem 0.75rem; text-align:right; color:var(--color-muted-foreground); white-space:nowrap;">
|
||||
{% if f.size is not None %}{{ f.size|filesizeformat }}{% endif %}
|
||||
</td>
|
||||
<td style="padding:0.5rem 0.75rem; text-align:right; width:1%;">
|
||||
{% if not f.is_dir %}
|
||||
<form method="post" action="/pharmacies/{{ pharmacy.pk }}/documents/delete/"
|
||||
hx-post="/pharmacies/{{ pharmacy.pk }}/documents/delete/"
|
||||
hx-target="#ph-documents-slot" hx-swap="innerHTML"
|
||||
hx-confirm="{% trans 'Удалить файл?' %}" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="name" value="{{ f.name }}">
|
||||
<button type="submit" title="{% trans 'Удалить' %}"
|
||||
style="border:none; background:none; cursor:pointer; color:var(--color-muted-foreground); font-size:0.9rem;">🗑</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding:0.875rem; font-size:0.8125rem; color:var(--color-muted-foreground); margin-bottom:0.875rem;">
|
||||
{% trans "В папке аптеки пока нет файлов." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/pharmacies/{{ pharmacy.pk }}/documents/upload/"
|
||||
hx-post="/pharmacies/{{ pharmacy.pk }}/documents/upload/"
|
||||
hx-target="#ph-documents-slot" hx-swap="innerHTML" hx-encoding="multipart/form-data"
|
||||
enctype="multipart/form-data"
|
||||
style="display:flex; gap:0.5rem; align-items:center;">
|
||||
{% csrf_token %}
|
||||
<input type="file" name="file" required
|
||||
style="font-size:0.8125rem; flex:1; min-width:0;">
|
||||
<button type="submit" class="btn btn-primary"
|
||||
style="font-size:0.8125rem; white-space:nowrap;">{% trans "Загрузить" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
Reference in New Issue
Block a user