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:
2026-07-28 11:22:19 +03:00
parent 19bc2874aa
commit b6ad270971
8 changed files with 519 additions and 0 deletions
+1
View File
@@ -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'),
+51
View File
@@ -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
+94
View File
@@ -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)
+14
View File
@@ -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'),
]
+123
View File
@@ -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')
+139
View File
@@ -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()
+18
View File
@@ -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>