reports: документы отдела с правкой через OnlyOffice
- ReportDocument (+ миграция 0005): файлы отдела в Nextcloud (06_Отчёты). - blank_office.py: генерация пустых Word/Excel для новых документов. - documents.py: доступ/путь/создание, права view/edit по отделу. - редактор OnlyOffice учится открывать report_doc (view/edit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -243,6 +243,36 @@ def document_upload(request):
|
||||
|
||||
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():
|
||||
@@ -313,6 +343,23 @@ def _resolve_token_relpath(payload: dict) -> tuple[str, str, bool] | None:
|
||||
if pharmacy is None:
|
||||
return None
|
||||
return f'{pharmacy_folder_relpath(pharmacy)}/{name}', name, True
|
||||
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
|
||||
user = get_user_model().objects.filter(pk=payload.get('uid')).first()
|
||||
|
||||
@@ -143,7 +143,25 @@ class NextcloudWebDAVService:
|
||||
self._raise(resp, relpath)
|
||||
return resp.content, resp.headers.get('content-type', 'application/octet-stream')
|
||||
|
||||
def ensure_parent_collections(self, relpath: str) -> None:
|
||||
"""Создаёт промежуточные папки WebDAV (MKCOL), если их ещё нет."""
|
||||
parts = [p for p in (relpath or '').strip('/').split('/') if p]
|
||||
if len(parts) < 2:
|
||||
return
|
||||
acc: list[str] = []
|
||||
for segment in parts[:-1]:
|
||||
acc.append(segment)
|
||||
folder = '/'.join(acc)
|
||||
try:
|
||||
resp = self._client.request('MKCOL', self._url(folder))
|
||||
except httpx.HTTPError as e:
|
||||
raise NextcloudServiceError(f'WebDAV MKCOL «{folder}»: {e}') from e
|
||||
if resp.status_code in (201, 405, 301, 200, 409):
|
||||
continue
|
||||
self._raise(resp, folder)
|
||||
|
||||
def upload(self, relpath: str, content: bytes, content_type: str | None = None) -> None:
|
||||
self.ensure_parent_collections(relpath)
|
||||
headers = {'Content-Type': content_type} if content_type else {}
|
||||
try:
|
||||
resp = self._client.put(self._url(relpath), content=content, headers=headers)
|
||||
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.reports.models import ReportPublication, ReportTemplate
|
||||
from apps.reports.models import ReportDocument, ReportPublication, ReportTemplate
|
||||
|
||||
|
||||
@admin.register(ReportTemplate)
|
||||
@@ -20,3 +20,13 @@ class ReportPublicationAdmin(admin.ModelAdmin):
|
||||
search_fields = ['department__name', 'department__code']
|
||||
raw_id_fields = ['department', 'published_by']
|
||||
readonly_fields = ['updated_at']
|
||||
|
||||
|
||||
@admin.register(ReportDocument)
|
||||
class ReportDocumentAdmin(admin.ModelAdmin):
|
||||
list_display = ['title', 'department', 'kind', 'filename', 'created_by', 'updated_at']
|
||||
list_filter = ['kind']
|
||||
search_fields = ['title', 'filename', 'department__name']
|
||||
raw_id_fields = ['department', 'created_by']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Пустые Word/Excel для новых документов отдела."""
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
import zipfile
|
||||
|
||||
|
||||
def empty_xlsx() -> bytes:
|
||||
from openpyxl import Workbook
|
||||
|
||||
buf = BytesIO()
|
||||
Workbook().save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def empty_docx() -> bytes:
|
||||
"""Минимальный OOXML, который открывает OnlyOffice / Word."""
|
||||
files = {
|
||||
'[Content_Types].xml': (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
'<Override PartName="/word/document.xml" '
|
||||
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
|
||||
'</Types>'
|
||||
),
|
||||
'_rels/.rels': (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
|
||||
'</Relationships>'
|
||||
),
|
||||
'word/document.xml': (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
|
||||
'<w:body><w:p><w:r><w:t></w:t></w:r></w:p></w:body></w:document>'
|
||||
),
|
||||
}
|
||||
buf = BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in files.items():
|
||||
zf.writestr(name, data)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Документы отдела на /reports/: доступ, путь в Nextcloud, создание файлов."""
|
||||
from __future__ import annotations
|
||||
|
||||
from django.utils.text import slugify
|
||||
|
||||
from apps.nextcloud_connector.access import safe_filename
|
||||
from apps.nextcloud_connector.services.sync_service import safe_component
|
||||
from apps.reports.it_monthly.builder import (
|
||||
user_can_manage_it_monthly_report,
|
||||
user_can_view_all_department_reports,
|
||||
)
|
||||
from apps.reports.models import ReportDocument
|
||||
from apps.reports.registry import visible_reports
|
||||
from core.portal_access import user_can_access
|
||||
|
||||
REPORTS_DAV_ROOT = '06_Отчёты'
|
||||
|
||||
|
||||
def user_can_open_reports_hub(user) -> bool:
|
||||
if not getattr(user, 'is_authenticated', False):
|
||||
return False
|
||||
if user_can_access(user, 'reports'):
|
||||
return True
|
||||
return bool(visible_reports(user))
|
||||
|
||||
|
||||
def user_can_view_report_document(user, doc: ReportDocument) -> bool:
|
||||
if not user_can_open_reports_hub(user):
|
||||
return False
|
||||
if user_can_view_all_department_reports(user):
|
||||
return True
|
||||
return getattr(user, 'department_id', None) == doc.department_id
|
||||
|
||||
|
||||
def user_can_edit_report_document(user, doc: ReportDocument) -> bool:
|
||||
if not user_can_view_report_document(user, doc):
|
||||
return False
|
||||
if user_can_view_all_department_reports(user):
|
||||
return True
|
||||
if user_can_manage_it_monthly_report(user):
|
||||
return getattr(user, 'department_id', None) == doc.department_id
|
||||
return False
|
||||
|
||||
|
||||
def user_can_create_report_document(user) -> bool:
|
||||
if not user_can_open_reports_hub(user):
|
||||
return False
|
||||
if not getattr(user, 'department_id', None):
|
||||
return False
|
||||
return user_can_manage_it_monthly_report(user) or user_can_view_all_department_reports(user)
|
||||
|
||||
|
||||
def visible_report_documents(user):
|
||||
if not user_can_open_reports_hub(user):
|
||||
return ReportDocument.objects.none()
|
||||
qs = ReportDocument.objects.select_related('department', 'created_by')
|
||||
if user_can_view_all_department_reports(user):
|
||||
return qs
|
||||
dept_id = getattr(user, 'department_id', None)
|
||||
if not dept_id:
|
||||
return ReportDocument.objects.none()
|
||||
return qs.filter(department_id=dept_id)
|
||||
|
||||
|
||||
def report_document_relpath(doc: ReportDocument) -> str:
|
||||
folder = safe_component(doc.department.code or doc.department.name)
|
||||
return f'{REPORTS_DAV_ROOT}/{folder}/{doc.filename}'
|
||||
|
||||
|
||||
def unique_filename(department_id: int, title: str, ext: str) -> str:
|
||||
base = slugify(title, allow_unicode=True) or 'document'
|
||||
candidate = safe_filename(f'{base}.{ext}') or f'document.{ext}'
|
||||
if not ReportDocument.objects.filter(department_id=department_id, filename=candidate).exists():
|
||||
return candidate
|
||||
n = 2
|
||||
while True:
|
||||
name = safe_filename(f'{base}-{n}.{ext}') or f'document-{n}.{ext}'
|
||||
if not ReportDocument.objects.filter(department_id=department_id, filename=name).exists():
|
||||
return name
|
||||
n += 1
|
||||
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 6.0.3 on 2026-09-10 11:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0020_alter_connectorconfig_extra_config'),
|
||||
('reports', '0004_report_publication'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ReportDocument',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='Название')),
|
||||
('filename', models.CharField(max_length=220, verbose_name='Имя файла')),
|
||||
('kind', models.CharField(choices=[('extra', 'Документ'), ('snapshot', 'Снимок отчёта')], default='extra', max_length=16, verbose_name='Тип')),
|
||||
('source_month', models.CharField(blank=True, max_length=7, verbose_name='Период отчёта')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='report_documents_created', to=settings.AUTH_USER_MODEL, verbose_name='Кто создал')),
|
||||
('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_documents', to='core.directory', verbose_name='Отдел')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Документ отчётов',
|
||||
'verbose_name_plural': 'Документы отчётов',
|
||||
'ordering': ['-updated_at'],
|
||||
'constraints': [models.UniqueConstraint(fields=('department', 'filename'), name='reports_document_unique_filename')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -150,3 +150,50 @@ class ReportPublication(models.Model):
|
||||
@property
|
||||
def is_published(self) -> bool:
|
||||
return self.status == self.STATUS_PUBLISHED
|
||||
|
||||
|
||||
class ReportDocument(models.Model):
|
||||
"""Файл отдела на странице отчётов: доп. документ или снимок сформированного отчёта."""
|
||||
|
||||
KIND_EXTRA = 'extra'
|
||||
KIND_SNAPSHOT = 'snapshot'
|
||||
KIND_CHOICES = [
|
||||
(KIND_EXTRA, _('Документ')),
|
||||
(KIND_SNAPSHOT, _('Снимок отчёта')),
|
||||
]
|
||||
|
||||
department = models.ForeignKey(
|
||||
'core.Directory',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='report_documents',
|
||||
verbose_name=_('Отдел'),
|
||||
)
|
||||
title = models.CharField(_('Название'), max_length=200)
|
||||
filename = models.CharField(_('Имя файла'), max_length=220)
|
||||
kind = models.CharField(_('Тип'), max_length=16, choices=KIND_CHOICES, default=KIND_EXTRA)
|
||||
source_month = models.CharField(_('Период отчёта'), max_length=7, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='report_documents_created',
|
||||
verbose_name=_('Кто создал'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Документ отчётов')
|
||||
verbose_name_plural = _('Документы отчётов')
|
||||
ordering = ['-updated_at']
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=['department', 'filename'],
|
||||
name='reports_document_unique_filename',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
@@ -472,3 +472,50 @@ class ReportTemplateTests(TestCase):
|
||||
})
|
||||
self.assertEqual(bad.status_code, 400)
|
||||
self.assertIn('Ошибка', bad.content.decode())
|
||||
|
||||
|
||||
class ReportDocumentTests(TestCase):
|
||||
def setUp(self):
|
||||
dept_type, _ = DirectoryType.objects.get_or_create(
|
||||
code='department', defaults={'name': 'Відділи', 'hierarchy_type': 'strict'},
|
||||
)
|
||||
self.dept = Directory.objects.create(
|
||||
directory_type=dept_type, code='it-docs', name='IT-docs',
|
||||
)
|
||||
self.head = make_employee(
|
||||
last_name='Глава', first_name='Отдел', username='head.docs',
|
||||
department=self.dept, user_location_type='office',
|
||||
)
|
||||
self.member = make_employee(
|
||||
last_name='Сотр', first_name='Отдел', username='mem.docs',
|
||||
department=self.dept, manager=self.head, user_location_type='office',
|
||||
)
|
||||
it_group = Group.objects.get(name='IT')
|
||||
self.head.groups.add(it_group)
|
||||
self.member.groups.add(it_group)
|
||||
self.client = Client()
|
||||
|
||||
def test_hub_shows_documents_block_for_head(self):
|
||||
login_2fa(self.client, self.head)
|
||||
r = self.client.get('/reports/')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertContains(r, 'Документы отдела')
|
||||
self.assertContains(r, 'Создать')
|
||||
|
||||
def test_member_cannot_create(self):
|
||||
login_2fa(self.client, self.member)
|
||||
r = self.client.post('/reports/documents/create/', {'title': 'План', 'file_type': 'docx'})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_head_creates_docx_when_storage_configured(self):
|
||||
from unittest.mock import patch
|
||||
from apps.reports.models import ReportDocument
|
||||
|
||||
login_2fa(self.client, self.head)
|
||||
with patch('apps.reports.views._store_report_file', return_value=None):
|
||||
r = self.client.post('/reports/documents/create/', {'title': 'План работ', 'file_type': 'docx'})
|
||||
self.assertEqual(r.status_code, 302)
|
||||
doc = ReportDocument.objects.get(title='План работ')
|
||||
self.assertTrue(doc.filename.endswith('.docx'))
|
||||
self.assertEqual(doc.kind, ReportDocument.KIND_EXTRA)
|
||||
self.assertIn('/reports/', r['Location'])
|
||||
|
||||
@@ -28,6 +28,26 @@ urlpatterns = [
|
||||
views.it_monthly_unpublish_view,
|
||||
name='reports_it_monthly_unpublish',
|
||||
),
|
||||
path(
|
||||
'reports/it/monthly/<str:month_id>/save-document/',
|
||||
views.it_monthly_save_document_view,
|
||||
name='reports_it_monthly_save_document',
|
||||
),
|
||||
path(
|
||||
'reports/documents/create/',
|
||||
views.report_document_create_view,
|
||||
name='report_document_create',
|
||||
),
|
||||
path(
|
||||
'reports/documents/<int:pk>/download/',
|
||||
views.report_document_download_view,
|
||||
name='report_document_download',
|
||||
),
|
||||
path(
|
||||
'reports/documents/<int:pk>/delete/',
|
||||
views.report_document_delete_view,
|
||||
name='report_document_delete',
|
||||
),
|
||||
path(
|
||||
'reports/it/monthly/<str:month_id>.md',
|
||||
views.it_monthly_md_view,
|
||||
|
||||
+159
-3
@@ -5,7 +5,9 @@ import json
|
||||
import re
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect
|
||||
from django.http import (
|
||||
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.decorators.http import require_POST
|
||||
@@ -19,7 +21,15 @@ from apps.reports.it_monthly import (
|
||||
user_can_manage_it_monthly_report,
|
||||
user_can_view_it_monthly_report,
|
||||
)
|
||||
from apps.reports.models import ReportTemplate
|
||||
from apps.reports.documents import (
|
||||
report_document_relpath,
|
||||
unique_filename,
|
||||
user_can_create_report_document,
|
||||
user_can_edit_report_document,
|
||||
user_can_view_report_document,
|
||||
visible_report_documents,
|
||||
)
|
||||
from apps.reports.models import ReportDocument, ReportTemplate
|
||||
from apps.reports.publication import (
|
||||
available_months_for_user,
|
||||
publication_for_user_month,
|
||||
@@ -46,6 +56,12 @@ def _default_it_template() -> ReportTemplate | None:
|
||||
return ReportTemplate.resolve(ReportTemplate.REPORT_IT_MONTHLY)
|
||||
|
||||
|
||||
def _onlyoffice_ready() -> bool:
|
||||
from apps.nextcloud_connector.onlyoffice import onlyoffice_is_ready
|
||||
|
||||
return onlyoffice_is_ready()
|
||||
|
||||
|
||||
def _resolve_it_month(request, month_raw: str | None) -> tuple[int, int] | HttpResponse:
|
||||
"""Выбрать месяц с учётом публикации; 403 если нет доступа к периоду."""
|
||||
months = available_months_for_user(request.user)
|
||||
@@ -84,6 +100,11 @@ def reports_hub_view(request):
|
||||
return render_page(request, 'reports.html', {
|
||||
'report_specs': specs,
|
||||
'report_rows': report_list_rows(request.user),
|
||||
'report_documents': list(visible_report_documents(request.user)),
|
||||
'can_create_report_document': user_can_create_report_document(request.user),
|
||||
'onlyoffice_ready': _onlyoffice_ready(),
|
||||
'reports_message': request.session.pop('reports_message', ''),
|
||||
'reports_error': request.session.pop('reports_error', ''),
|
||||
'can_manage_templates': user_can_manage_templates(request.user),
|
||||
})
|
||||
|
||||
@@ -98,7 +119,11 @@ def it_monthly_view(request):
|
||||
if isinstance(resolved, HttpResponse):
|
||||
return resolved
|
||||
year, month = resolved
|
||||
return render_page(request, 'reports-it-monthly.html', _it_monthly_page_context(request, year, month))
|
||||
return render_page(request, 'reports-it-monthly.html', {
|
||||
**_it_monthly_page_context(request, year, month),
|
||||
'can_save_report_document': user_can_create_report_document(request.user),
|
||||
'onlyoffice_ready': _onlyoffice_ready(),
|
||||
})
|
||||
|
||||
|
||||
def _export_with_template(request, month_id, fmt: str):
|
||||
@@ -199,6 +224,137 @@ def redirect_legacy_it_report(request, month_id, fmt: str):
|
||||
return HttpResponseRedirect(f'/reports/it/monthly/{month_id}.{fmt}')
|
||||
|
||||
|
||||
def _store_report_file(relpath: str, content: bytes, content_type: str | None = None) -> str | None:
|
||||
from apps.nextcloud_connector.nextcloud_service import NextcloudConfigError, NextcloudServiceError
|
||||
from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService
|
||||
|
||||
try:
|
||||
NextcloudWebDAVService().upload(relpath, content, content_type)
|
||||
except NextcloudConfigError:
|
||||
return 'Файловое хранилище (Nextcloud) ещё не настроено.'
|
||||
except NextcloudServiceError:
|
||||
return 'Не удалось сохранить файл в хранилище.'
|
||||
return None
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def report_document_create_view(request):
|
||||
if not user_can_create_report_document(request.user):
|
||||
return HttpResponseForbidden()
|
||||
title = (request.POST.get('title') or '').strip()
|
||||
ext = (request.POST.get('file_type') or 'docx').strip().lower()
|
||||
if ext not in ('docx', 'xlsx'):
|
||||
ext = 'docx'
|
||||
if not title:
|
||||
request.session['reports_error'] = 'Укажите название документа.'
|
||||
return HttpResponseRedirect('/reports/')
|
||||
|
||||
from apps.reports.blank_office import empty_docx, empty_xlsx
|
||||
|
||||
filename = unique_filename(request.user.department_id, title, ext)
|
||||
doc = ReportDocument.objects.create(
|
||||
department_id=request.user.department_id,
|
||||
title=title,
|
||||
filename=filename,
|
||||
kind=ReportDocument.KIND_EXTRA,
|
||||
created_by=request.user,
|
||||
)
|
||||
payload = empty_xlsx() if ext == 'xlsx' else empty_docx()
|
||||
ctype = (
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
if ext == 'xlsx'
|
||||
else 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
)
|
||||
err = _store_report_file(report_document_relpath(doc), payload, ctype)
|
||||
if err:
|
||||
doc.delete()
|
||||
request.session['reports_error'] = err
|
||||
return HttpResponseRedirect('/reports/')
|
||||
if _onlyoffice_ready():
|
||||
return HttpResponseRedirect(f'/documents/edit/?report_doc={doc.pk}')
|
||||
request.session['reports_message'] = f'Документ «{title}» создан. OnlyOffice пока не настроен — откройте файл позже.'
|
||||
return HttpResponseRedirect('/reports/')
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def report_document_delete_view(request, pk):
|
||||
doc = get_object_or_404(ReportDocument, pk=pk)
|
||||
if not user_can_edit_report_document(request.user, doc):
|
||||
return HttpResponseForbidden()
|
||||
from apps.nextcloud_connector.nextcloud_service import NextcloudConfigError, NextcloudServiceError
|
||||
from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService
|
||||
|
||||
relpath = report_document_relpath(doc)
|
||||
try:
|
||||
NextcloudWebDAVService().delete(relpath)
|
||||
except (NextcloudConfigError, NextcloudServiceError):
|
||||
pass
|
||||
title = doc.title
|
||||
doc.delete()
|
||||
request.session['reports_message'] = f'Документ «{title}» удалён.'
|
||||
return HttpResponseRedirect('/reports/')
|
||||
|
||||
|
||||
@login_required
|
||||
def report_document_download_view(request, pk):
|
||||
doc = get_object_or_404(ReportDocument, pk=pk)
|
||||
if not user_can_view_report_document(request.user, doc):
|
||||
return HttpResponseForbidden()
|
||||
from apps.nextcloud_connector.nextcloud_service import NextcloudConfigError, NextcloudServiceError
|
||||
from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService
|
||||
|
||||
try:
|
||||
content, ctype = NextcloudWebDAVService().download(report_document_relpath(doc))
|
||||
except (NextcloudConfigError, NextcloudServiceError):
|
||||
raise Http404('Файл недоступен.')
|
||||
resp = HttpResponse(content, content_type=ctype)
|
||||
resp['Content-Disposition'] = f'attachment; filename*=UTF-8\'\'{doc.filename}'
|
||||
return resp
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def it_monthly_save_document_view(request, month_id):
|
||||
if not user_can_create_report_document(request.user):
|
||||
return HttpResponseForbidden()
|
||||
year, month = parse_month_id(month_id)
|
||||
if not user_can_access_it_monthly_month(request.user, year, month):
|
||||
return HttpResponseForbidden()
|
||||
report = build_monthly_report(year, month)
|
||||
tpl = _default_it_template()
|
||||
try:
|
||||
payload = render_report(report, tpl, 'docx')
|
||||
except TemplateRenderError as exc:
|
||||
request.session['reports_error'] = f'Не удалось собрать Word: {exc}'
|
||||
return HttpResponseRedirect(f'/reports/it/monthly/?month={year:04d}-{month:02d}')
|
||||
|
||||
title = f'IT-отчёт {report["title"]}'
|
||||
filename = unique_filename(request.user.department_id, title, 'docx')
|
||||
doc = ReportDocument.objects.create(
|
||||
department_id=request.user.department_id,
|
||||
title=title,
|
||||
filename=filename,
|
||||
kind=ReportDocument.KIND_SNAPSHOT,
|
||||
source_month=report['id'],
|
||||
created_by=request.user,
|
||||
)
|
||||
err = _store_report_file(
|
||||
report_document_relpath(doc),
|
||||
payload,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
)
|
||||
if err:
|
||||
doc.delete()
|
||||
request.session['reports_error'] = err
|
||||
return HttpResponseRedirect(f'/reports/it/monthly/?month={year:04d}-{month:02d}')
|
||||
if _onlyoffice_ready():
|
||||
return HttpResponseRedirect(f'/documents/edit/?report_doc={doc.pk}')
|
||||
request.session['reports_message'] = f'Отчёт сохранён в документы: «{title}».'
|
||||
return HttpResponseRedirect('/reports/')
|
||||
|
||||
|
||||
# ── Шаблоны ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
{% if back.kind == 'pharmacy' %}
|
||||
<a href="/pharmacies/{{ back.pharmacy_id }}/"
|
||||
class="btn btn-secondary" style="text-decoration:none;">{% trans "К аптеке" %}</a>
|
||||
{% elif back.kind == 'report_doc' %}
|
||||
<a href="/reports/"
|
||||
hx-get="/reports/" hx-target="#main-content" hx-push-url="/reports/" hx-indicator="#loader"
|
||||
class="btn btn-secondary" style="text-decoration:none;">{% trans "К отчётам" %}</a>
|
||||
{% else %}
|
||||
<a href="/documents/"
|
||||
hx-get="/documents/" hx-target="#main-content" hx-push-url="/documents/" hx-indicator="#loader"
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if can_save_report_document %}
|
||||
<form method="post" action="/reports/it/monthly/{{ report.id }}/save-document/" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-secondary">{% trans "Сохранить в документы" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-secondary"
|
||||
onclick="itReportPreviewOpen('/reports/it/monthly/{{ report.id }}/preview/', '{{ report.title|escapejs }}')">
|
||||
{% trans "Предпросмотр" %}
|
||||
|
||||
@@ -62,4 +62,80 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if reports_message %}
|
||||
<div style="margin-top:1rem; padding:0.65rem 0.9rem; border-radius:0.5rem; background:color-mix(in srgb, #16a34a 12%, var(--color-card)); border:1px solid color-mix(in srgb, #16a34a 30%, var(--color-border)); font-size:0.8125rem;">{{ reports_message }}</div>
|
||||
{% endif %}
|
||||
{% if reports_error %}
|
||||
<div style="margin-top:1rem; padding:0.65rem 0.9rem; border-radius:0.5rem; background:color-mix(in srgb, #dc2626 12%, var(--color-card)); border:1px solid color-mix(in srgb, #dc2626 30%, var(--color-border)); font-size:0.8125rem;">{{ reports_error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-top:1.5rem; 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); overflow:hidden;">
|
||||
<div style="display:flex; flex-wrap:wrap; align-items:flex-start; justify-content:space-between; gap:0.75rem; padding:0.875rem 1.25rem; border-bottom:1px solid var(--color-border);">
|
||||
<div>
|
||||
<div style="font-size:0.9375rem; font-weight:500;">{% trans "Документы отдела" %}</div>
|
||||
<p style="font-size:0.75rem; color:var(--color-muted-foreground); margin:0.25rem 0 0;">
|
||||
{% trans "Дополнительные файлы и сохранённые отчёты. Правка — в OnlyOffice." %}
|
||||
</p>
|
||||
</div>
|
||||
{% if can_create_report_document %}
|
||||
<form method="post" action="{% url 'report_document_create' %}"
|
||||
style="display:flex; gap:0.5rem; flex-wrap:wrap; align-items:center; margin:0;">
|
||||
{% csrf_token %}
|
||||
<input type="text" name="title" required class="input" maxlength="200"
|
||||
placeholder="{% trans 'Название документа' %}" style="min-width:14rem;">
|
||||
<select name="file_type" class="input">
|
||||
<option value="docx">{% trans "Word" %}</option>
|
||||
<option value="xlsx">{% trans "Excel" %}</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary" style="font-size:0.75rem;">{% trans "Создать" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if report_documents %}
|
||||
<table style="width:100%; border-collapse:collapse; font-size:0.8125rem;">
|
||||
<thead>
|
||||
<tr style="background:var(--color-muted);">
|
||||
<th style="padding:0.625rem 1.25rem; text-align:left; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--color-muted-foreground);">{% trans "Документ" %}</th>
|
||||
<th style="padding:0.625rem 1rem; text-align:left; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--color-muted-foreground);">{% trans "Отдел" %}</th>
|
||||
<th style="padding:0.625rem 1rem; text-align:left; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.04em; color:var(--color-muted-foreground);">{% trans "Обновлён" %}</th>
|
||||
<th style="padding:0.625rem 1rem;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for doc in report_documents %}
|
||||
<tr class="tr-hover" style="border-top:1px solid var(--color-border);">
|
||||
<td style="padding:0.75rem 1.25rem;">
|
||||
<div style="font-weight:600;">{{ doc.title }}</div>
|
||||
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.15rem;">
|
||||
{% if doc.kind == 'snapshot' %}{% trans "Снимок отчёта" %}{% else %}{% trans "Документ" %}{% endif %}
|
||||
· {{ doc.filename }}
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:0.75rem 1rem; white-space:nowrap;">{{ doc.department.name }}</td>
|
||||
<td style="padding:0.75rem 1rem; white-space:nowrap; color:var(--color-muted-foreground);">{{ doc.updated_at|date:"d.m.Y H:i" }}</td>
|
||||
<td style="padding:0.75rem 1.25rem; text-align:right; white-space:nowrap;">
|
||||
{% if onlyoffice_ready %}
|
||||
<a href="/documents/edit/?report_doc={{ doc.pk }}" class="btn btn-secondary" style="font-size:0.75rem; text-decoration:none; border:1px solid var(--color-border);">{% trans "Открыть" %}</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'report_document_download' doc.pk %}" class="btn btn-secondary" style="font-size:0.75rem; text-decoration:none; border:1px solid var(--color-border);">{% trans "Скачать" %}</a>
|
||||
{% if can_create_report_document %}
|
||||
<form method="post" action="{% url 'report_document_delete' doc.pk %}" style="display:inline;"
|
||||
onsubmit="return confirm('{% trans "Удалить документ?" %}');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-ghost" style="font-size:0.75rem;">{% trans "Удалить" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div style="padding:1.25rem; font-size:0.8125rem; color:var(--color-muted-foreground);">
|
||||
{% trans "Пока нет дополнительных документов." %}
|
||||
{% if can_create_report_document %}{% trans "Создайте Word или Excel — файл откроется в OnlyOffice." %}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user