diff --git a/apps/nextcloud_connector/views.py b/apps/nextcloud_connector/views.py index 384fb00..5dc44e0 100644 --- a/apps/nextcloud_connector/views.py +++ b/apps/nextcloud_connector/views.py @@ -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() diff --git a/apps/nextcloud_connector/webdav_service.py b/apps/nextcloud_connector/webdav_service.py index 21940c5..e621a53 100644 --- a/apps/nextcloud_connector/webdav_service.py +++ b/apps/nextcloud_connector/webdav_service.py @@ -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) diff --git a/apps/reports/admin.py b/apps/reports/admin.py index 0aea64e..19f9b59 100644 --- a/apps/reports/admin.py +++ b/apps/reports/admin.py @@ -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'] + diff --git a/apps/reports/blank_office.py b/apps/reports/blank_office.py new file mode 100644 index 0000000..ec485e3 --- /dev/null +++ b/apps/reports/blank_office.py @@ -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': ( + '' + '' + '' + '' + '' + '' + ), + '_rels/.rels': ( + '' + '' + '' + '' + ), + 'word/document.xml': ( + '' + '' + '' + ), + } + 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() diff --git a/apps/reports/documents.py b/apps/reports/documents.py new file mode 100644 index 0000000..a46b8cb --- /dev/null +++ b/apps/reports/documents.py @@ -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 diff --git a/apps/reports/migrations/0005_report_document.py b/apps/reports/migrations/0005_report_document.py new file mode 100644 index 0000000..856cb19 --- /dev/null +++ b/apps/reports/migrations/0005_report_document.py @@ -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')], + }, + ), + ] diff --git a/apps/reports/models.py b/apps/reports/models.py index b7452aa..490512c 100644 --- a/apps/reports/models.py +++ b/apps/reports/models.py @@ -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 + diff --git a/apps/reports/tests.py b/apps/reports/tests.py index 097e0f8..3c2bcef 100644 --- a/apps/reports/tests.py +++ b/apps/reports/tests.py @@ -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']) diff --git a/apps/reports/urls.py b/apps/reports/urls.py index c12d929..6f1afb1 100644 --- a/apps/reports/urls.py +++ b/apps/reports/urls.py @@ -28,6 +28,26 @@ urlpatterns = [ views.it_monthly_unpublish_view, name='reports_it_monthly_unpublish', ), + path( + 'reports/it/monthly//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//download/', + views.report_document_download_view, + name='report_document_download', + ), + path( + 'reports/documents//delete/', + views.report_document_delete_view, + name='report_document_delete', + ), path( 'reports/it/monthly/.md', views.it_monthly_md_view, diff --git a/apps/reports/views.py b/apps/reports/views.py index 995c9a8..31585b8 100644 --- a/apps/reports/views.py +++ b/apps/reports/views.py @@ -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/') + + # ── Шаблоны ────────────────────────────────────────────────────────────── diff --git a/templates/pages/documents-editor.html b/templates/pages/documents-editor.html index fd8c4ed..66f5bb0 100644 --- a/templates/pages/documents-editor.html +++ b/templates/pages/documents-editor.html @@ -11,6 +11,10 @@ {% if back.kind == 'pharmacy' %} {% trans "К аптеке" %} + {% elif back.kind == 'report_doc' %} + {% trans "К отчётам" %} {% else %} {% endif %} {% endif %} + {% if can_save_report_document %} +
+ {% csrf_token %} + +
+ {% endif %} + + {% endif %} + + {% if report_documents %} + + + + + + + + + + + {% for doc in report_documents %} + + + + + + + {% endfor %} + +
{% trans "Документ" %}{% trans "Отдел" %}{% trans "Обновлён" %}
+
{{ doc.title }}
+
+ {% if doc.kind == 'snapshot' %}{% trans "Снимок отчёта" %}{% else %}{% trans "Документ" %}{% endif %} + · {{ doc.filename }} +
+
{{ doc.department.name }}{{ doc.updated_at|date:"d.m.Y H:i" }} + {% if onlyoffice_ready %} + {% trans "Открыть" %} + {% endif %} + {% trans "Скачать" %} + {% if can_create_report_document %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% else %} +
+ {% trans "Пока нет дополнительных документов." %} + {% if can_create_report_document %}{% trans "Создайте Word или Excel — файл откроется в OnlyOffice." %}{% endif %} +
+ {% endif %} + +