Nextcloud connector: страница «Документы» подключена к хранилищу
Заменяет статичный макет documents.html реальным браузером Group Folders: - access.py: accessible_sections (разделы по правам: Компания/Обучение/Обмен, отдел сотрудника, Кадровые дела для суперюзера) + safe_relpath (посегментная валидация подпути от path traversal); раздел резолвится только из whitelist - views: documents_browse/download/upload с проверкой раздела и пути на каждом эндпоинте, запись — только в writable-разделы - documents.html: вкладки разделов + ленивый HTMX-браузер с хлебными крошками, навигацией по папкам, скачиванием и загрузкой - documents_view отдаёт доступные разделы; тесты разделов, путей и авторизации Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,57 @@ def can_access_pharmacy_docs(user, pharmacy) -> bool:
|
||||
)
|
||||
|
||||
|
||||
# --- Общий браузер документов (страница /documents/) ---
|
||||
|
||||
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)
|
||||
sections = [
|
||||
{'key': 'company', 'label': 'Компания', 'relpath': '01_Компания', 'writable': is_staff},
|
||||
{'key': 'training', 'label': 'Обучение', 'relpath': '05_Обучение', 'writable': is_staff},
|
||||
]
|
||||
dept = getattr(user, 'department', None)
|
||||
if dept is not None:
|
||||
sections.append({'key': 'dept', 'label': dept.name,
|
||||
'relpath': f'03_Отделы/{dept.name}', 'writable': True})
|
||||
sections.append({'key': 'exchange', 'label': 'Обмен', 'relpath': '99_Обмен', 'writable': True})
|
||||
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 s
|
||||
return None
|
||||
|
||||
|
||||
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, если оно небезопасно.
|
||||
|
||||
|
||||
@@ -165,3 +165,85 @@ class DocumentsViewTests(TestCase):
|
||||
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)
|
||||
|
||||
|
||||
from apps.nextcloud_connector.access import (
|
||||
accessible_sections, get_section, safe_relpath,
|
||||
)
|
||||
|
||||
|
||||
class SafeRelpathTests(TestCase):
|
||||
def test_empty_is_root(self):
|
||||
self.assertEqual(safe_relpath(''), '')
|
||||
self.assertEqual(safe_relpath(None), '')
|
||||
|
||||
def test_valid_nested(self):
|
||||
self.assertEqual(safe_relpath('Приказы/2026'), 'Приказы/2026')
|
||||
self.assertEqual(safe_relpath('/Приказы/'), 'Приказы')
|
||||
|
||||
def test_rejects_traversal(self):
|
||||
for p in ('../etc', 'a/../b', 'a/./b', 'a//b', 'a/.hidden'):
|
||||
self.assertIsNone(safe_relpath(p), f'должно отклонить: {p!r}')
|
||||
|
||||
|
||||
class SectionsTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
common = dict(mattermost_sync_enabled=False, ad_sync_enabled=False,
|
||||
hire_date=datetime.date(2020, 1, 1))
|
||||
cls.plain = Employee.objects.create_user(username='plain', **common)
|
||||
cls.staff = Employee.objects.create_user(username='st', is_staff=True, **common)
|
||||
cls.root = Employee.objects.create_user(
|
||||
username='root', is_staff=True, is_superuser=True, **common)
|
||||
|
||||
def _keys(self, user):
|
||||
return {s['key'] for s in accessible_sections(user)}
|
||||
|
||||
def test_plain_user_sections(self):
|
||||
self.assertEqual(self._keys(self.plain), {'company', 'training', 'exchange'})
|
||||
|
||||
def test_company_writable_only_for_staff(self):
|
||||
company_plain = get_section(self.plain, 'company')
|
||||
company_staff = get_section(self.staff, 'company')
|
||||
self.assertFalse(company_plain['writable'])
|
||||
self.assertTrue(company_staff['writable'])
|
||||
|
||||
def test_superuser_gets_hr(self):
|
||||
self.assertIn('hr', self._keys(self.root))
|
||||
self.assertNotIn('hr', self._keys(self.staff))
|
||||
|
||||
|
||||
class DocumentsBrowserViewTests(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = Employee.objects.create_user(
|
||||
username='browser', mattermost_sync_enabled=False, ad_sync_enabled=False,
|
||||
hire_date=datetime.date(2020, 1, 1))
|
||||
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
|
||||
def test_browse_valid_section_ok(self):
|
||||
self.client.force_login(self.user)
|
||||
r = self.client.get('/documents/browse/?section=company')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertContains(r, 'не настроен')
|
||||
|
||||
def test_browse_inaccessible_section_forbidden(self):
|
||||
self.client.force_login(self.user)
|
||||
r = self.client.get('/documents/browse/?section=hr') # не суперюзер
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_browse_traversal_path_bad_request(self):
|
||||
self.client.force_login(self.user)
|
||||
r = self.client.get('/documents/browse/?section=company&path=../04_Кадровые дела')
|
||||
self.assertEqual(r.status_code, 400)
|
||||
|
||||
def test_upload_to_readonly_section_forbidden(self):
|
||||
self.client.force_login(self.user) # обычный юзер: company только чтение
|
||||
r = self.client.post('/documents/upload/', {'section': 'company', 'path': ''})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_anonymous_redirected(self):
|
||||
r = self.client.get('/documents/browse/?section=company')
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
@@ -11,4 +11,9 @@ urlpatterns = [
|
||||
views.pharmacy_document_upload, name='pharmacy_document_upload'),
|
||||
path('pharmacies/<int:pk>/documents/delete/',
|
||||
views.pharmacy_document_delete, name='pharmacy_document_delete'),
|
||||
|
||||
# Общий браузер документов (страница /documents/)
|
||||
path('documents/browse/', views.documents_browse, name='documents_browse'),
|
||||
path('documents/download/', views.document_download, name='document_download'),
|
||||
path('documents/upload/', views.document_upload, name='document_upload'),
|
||||
]
|
||||
|
||||
@@ -15,6 +15,7 @@ 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,
|
||||
accessible_sections, get_section, safe_relpath,
|
||||
)
|
||||
from apps.nextcloud_connector.nextcloud_service import (
|
||||
NextcloudConfigError, NextcloudServiceError,
|
||||
@@ -121,3 +122,97 @@ def pharmacy_document_delete(request, pk):
|
||||
logger.warning(f'Nextcloud delete (аптека {pharmacy.pk}, {name}): {e}')
|
||||
return _render_documents(request, pharmacy, 'Не удалось удалить файл.', 'error')
|
||||
return _render_documents(request, pharmacy, f'Файл «{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('Некорректный путь.')
|
||||
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 _render_browser(request, section, sub, message=None, message_type=None):
|
||||
files, error = None, None
|
||||
try:
|
||||
files = NextcloudWebDAVService().list_dir(_section_relpath(section, sub))
|
||||
for f in files: # путь для навигации в подпапку / скачивания файла
|
||||
f['path'] = f'{sub}/{f["name"]}' if sub else f['name']
|
||||
except NextcloudConfigError:
|
||||
error = 'not_configured'
|
||||
except NextcloudServiceError as e:
|
||||
error = str(e)
|
||||
logger.warning(f'Nextcloud docs ({section["key"]}/{sub}): {e}')
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@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('Некорректное имя файла.')
|
||||
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('Файл недоступен.')
|
||||
resp = HttpResponse(content, content_type=ctype)
|
||||
resp['Content-Disposition'] = f'attachment; filename*=UTF-8\'\'{name}'
|
||||
return resp
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def document_upload(request):
|
||||
resolved, err = _resolve_section_path(request, 'POST')
|
||||
if err:
|
||||
return err
|
||||
section, sub = resolved
|
||||
if not section['writable']:
|
||||
return HttpResponseForbidden('Раздел доступен только для чтения.')
|
||||
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')
|
||||
return _render_browser(request, section, sub, f'Файл «{name}» загружен.', 'success')
|
||||
|
||||
+4
-1
@@ -373,7 +373,10 @@ def reports_view(request):
|
||||
|
||||
@login_required
|
||||
def documents_view(request):
|
||||
return render_page(request, 'documents.html')
|
||||
from apps.nextcloud_connector.access import accessible_sections
|
||||
return render_page(request, 'documents.html', {
|
||||
'sections': accessible_sections(request.user),
|
||||
})
|
||||
|
||||
@login_required
|
||||
def recruiting_view(request):
|
||||
|
||||
@@ -1,90 +1,46 @@
|
||||
{% load i18n %}
|
||||
<div class="page-content">
|
||||
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:1.5rem;">
|
||||
<div>
|
||||
<h1 style="font-size:1.25rem; font-weight:500; color:var(--color-foreground);">Документи</h1>
|
||||
<p style="font-size:0.875rem; color:var(--color-muted-foreground); margin-top:0.25rem;">Шаблони, накази та кадрові документи</p>
|
||||
</div>
|
||||
<button class="btn btn-primary">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" x2="12" y1="5" y2="19"/><line x1="5" x2="19" y1="12" y2="12"/>
|
||||
</svg>
|
||||
Завантажити
|
||||
</button>
|
||||
<div style="margin-bottom:1.5rem;">
|
||||
<h1 style="font-size:1.25rem; font-weight:500; color:var(--color-foreground);">{% trans "Документы" %}</h1>
|
||||
<p style="font-size:0.875rem; color:var(--color-muted-foreground); margin-top:0.25rem;">{% trans "Файловое хранилище: общие документы, обучение, отделы" %}</p>
|
||||
</div>
|
||||
|
||||
<!-- Фільтри -->
|
||||
<div style="display:flex; gap:2px; background:var(--color-muted); border-radius:var(--radius); padding:3px; width:fit-content; margin-bottom:1.25rem;">
|
||||
<button style="padding:0.3125rem 1rem; border-radius:calc(var(--radius) - 2px); font-size:0.8125rem; font-weight:500; border:none; cursor:pointer; background:var(--color-card); color:var(--color-foreground);">Всі</button>
|
||||
<button style="padding:0.3125rem 1rem; border-radius:calc(var(--radius) - 2px); font-size:0.8125rem; font-weight:500; border:none; cursor:pointer; background:transparent; color:var(--color-muted-foreground);">Накази</button>
|
||||
<button style="padding:0.3125rem 1rem; border-radius:calc(var(--radius) - 2px); font-size:0.8125rem; font-weight:500; border:none; cursor:pointer; background:transparent; color:var(--color-muted-foreground);">Шаблони</button>
|
||||
<button style="padding:0.3125rem 1rem; border-radius:calc(var(--radius) - 2px); font-size:0.8125rem; font-weight:500; border:none; cursor:pointer; background:transparent; color:var(--color-muted-foreground);">Договори</button>
|
||||
{% if sections %}
|
||||
<!-- Разделы хранилища -->
|
||||
<div style="display:flex; gap:2px; background:var(--color-muted); border-radius:var(--radius); padding:3px; width:fit-content; margin-bottom:1.25rem; flex-wrap:wrap;">
|
||||
{% for s in sections %}
|
||||
<button type="button" class="doc-section-btn{% if forloop.first %} doc-section-active{% endif %}" data-section="{{ s.key }}"
|
||||
onclick="docSectionSwitch('{{ s.key }}')"
|
||||
style="padding:0.3125rem 1rem; border-radius:calc(var(--radius) - 2px); font-size:0.8125rem; font-weight:500; border:none; cursor:pointer; {% if forloop.first %}background:var(--color-card); color:var(--color-foreground);{% else %}background:transparent; color:var(--color-muted-foreground);{% endif %}">{{ s.label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0/0.05);">
|
||||
<div style="overflow-x:auto;">
|
||||
<table style="width:100%; border-collapse:collapse; font-size:0.875rem;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid var(--color-border);">
|
||||
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Назва</th>
|
||||
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Тип</th>
|
||||
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Дата</th>
|
||||
<th style="padding:0.75rem 1rem; text-align:left; color:var(--color-muted-foreground); font-weight:600; font-size:0.8125rem;">Розмір</th>
|
||||
<th style="padding:0.75rem 1rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid var(--color-border);" class="tr-hover">
|
||||
<td style="padding:1rem;">
|
||||
<div style="display:flex; align-items:center; gap:0.625rem;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-muted-foreground)" stroke-width="2"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>
|
||||
<span style="font-weight:500; color:var(--color-card-foreground);">Наказ про прийом на роботу №42</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:1rem;"><span class="badge badge-info">Наказ</span></td>
|
||||
<td style="padding:1rem; color:var(--color-muted-foreground);">01.04.2026</td>
|
||||
<td style="padding:1rem; color:var(--color-muted-foreground);">48 КБ</td>
|
||||
<td style="padding:1rem; text-align:right;">
|
||||
<button class="btn btn-ghost btn-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr style="border-bottom:1px solid var(--color-border);" class="tr-hover">
|
||||
<td style="padding:1rem;">
|
||||
<div style="display:flex; align-items:center; gap:0.625rem;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-muted-foreground)" stroke-width="2"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>
|
||||
<span style="font-weight:500; color:var(--color-card-foreground);">Трудовий договір — шаблон</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:1rem;"><span class="badge badge-muted">Шаблон</span></td>
|
||||
<td style="padding:1rem; color:var(--color-muted-foreground);">15.01.2026</td>
|
||||
<td style="padding:1rem; color:var(--color-muted-foreground);">112 КБ</td>
|
||||
<td style="padding:1rem; text-align:right;">
|
||||
<button class="btn btn-ghost btn-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td style="padding:1rem;">
|
||||
<div style="display:flex; align-items:center; gap:0.625rem;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--color-muted-foreground)" stroke-width="2"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>
|
||||
<span style="font-weight:500; color:var(--color-card-foreground);">Посадова інструкція — Фармацевт</span>
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:1rem;"><span class="badge badge-muted">Шаблон</span></td>
|
||||
<td style="padding:1rem; color:var(--color-muted-foreground);">10.12.2025</td>
|
||||
<td style="padding:1rem; color:var(--color-muted-foreground);">78 КБ</td>
|
||||
<td style="padding:1rem; text-align:right;">
|
||||
<button class="btn btn-ghost btn-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0/0.05); padding:1.25rem;">
|
||||
<div id="documents-browser"
|
||||
style="font-size:0.8125rem; color:var(--color-muted-foreground);"
|
||||
hx-get="/documents/browse/?section={{ sections.0.key }}"
|
||||
hx-trigger="load" hx-swap="innerHTML">
|
||||
{% trans "Загрузка…" %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); padding:1.5rem; font-size:0.875rem; color:var(--color-muted-foreground);">
|
||||
{% trans "Нет доступных разделов документов." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function docSectionSwitch(key) {
|
||||
document.querySelectorAll('.doc-section-btn').forEach(function (btn) {
|
||||
var active = btn.getAttribute('data-section') === key;
|
||||
btn.classList.toggle('doc-section-active', active);
|
||||
btn.style.background = active ? 'var(--color-card)' : 'transparent';
|
||||
btn.style.color = active ? 'var(--color-foreground)' : 'var(--color-muted-foreground)';
|
||||
});
|
||||
htmx.ajax('GET', '/documents/browse/?section=' + encodeURIComponent(key),
|
||||
{ target: '#documents-browser', swap: 'innerHTML' });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
{% load i18n %}
|
||||
{% 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 %}
|
||||
|
||||
<!-- Хлебные крошки -->
|
||||
<div style="display:flex; align-items:center; gap:0.375rem; flex-wrap:wrap; margin-bottom:1rem; font-size:0.8125rem;">
|
||||
<a href="#" hx-get="/documents/browse/?section={{ section.key }}" hx-target="#documents-browser" hx-swap="innerHTML"
|
||||
style="color:var(--color-primary); text-decoration:none; font-weight:500;">{{ section.label }}</a>
|
||||
{% for c in crumbs %}
|
||||
<span style="color:var(--color-muted-foreground);">/</span>
|
||||
{% if forloop.last %}
|
||||
<span style="color:var(--color-foreground);">{{ c.name }}</span>
|
||||
{% else %}
|
||||
<a href="#" hx-get="/documents/browse/?section={{ section.key }}&path={{ c.path|urlencode }}"
|
||||
hx-target="#documents-browser" hx-swap="innerHTML"
|
||||
style="color:var(--color-primary); text-decoration:none;">{{ c.name }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% 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:1rem;">
|
||||
<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);" class="tr-hover">
|
||||
<td style="padding:0.5rem 0.75rem;">
|
||||
{% if f.is_dir %}
|
||||
<a href="#" hx-get="/documents/browse/?section={{ section.key }}&path={{ f.path|urlencode }}"
|
||||
hx-target="#documents-browser" hx-swap="innerHTML"
|
||||
style="color:var(--color-foreground); text-decoration:none; font-weight:500;">📁 {{ f.name }}</a>
|
||||
{% else %}
|
||||
<a href="/documents/download/?section={{ section.key }}&path={{ sub|urlencode }}&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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding:0.875rem; font-size:0.8125rem; color:var(--color-muted-foreground); margin-bottom:1rem;">
|
||||
{% trans "В этой папке пока нет файлов." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if section.writable %}
|
||||
<form method="post" action="/documents/upload/"
|
||||
hx-post="/documents/upload/" hx-target="#documents-browser" 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="hidden" name="section" value="{{ section.key }}">
|
||||
<input type="hidden" name="path" value="{{ sub }}">
|
||||
<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 %}
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user