From 555dbfb1577d441191b91f5cd41e0a5d53b35491 Mon Sep 17 00:00:00 2001 From: lokses Date: Mon, 14 Sep 2026 15:00:27 +0300 Subject: [PATCH] Add Word/Excel upload and draft/publish for department report docs. Managers can upload files as drafts; members only see published documents. Co-authored-by: Cursor --- apps/reports/admin.py | 11 ++- apps/reports/documents.py | 65 +++++++++++++- .../migrations/0006_reportdocument_publish.py | 56 ++++++++++++ apps/reports/models.py | 24 ++++++ apps/reports/tests.py | 44 ++++++++++ apps/reports/urls.py | 15 ++++ apps/reports/views.py | 85 ++++++++++++++++++- templates/pages/reports.html | 38 ++++++++- 8 files changed, 325 insertions(+), 13 deletions(-) create mode 100644 apps/reports/migrations/0006_reportdocument_publish.py diff --git a/apps/reports/admin.py b/apps/reports/admin.py index 19f9b59..d828c19 100644 --- a/apps/reports/admin.py +++ b/apps/reports/admin.py @@ -24,9 +24,12 @@ class ReportPublicationAdmin(admin.ModelAdmin): @admin.register(ReportDocument) class ReportDocumentAdmin(admin.ModelAdmin): - list_display = ['title', 'department', 'kind', 'filename', 'created_by', 'updated_at'] - list_filter = ['kind'] + list_display = [ + 'title', 'department', 'kind', 'status', 'filename', + 'created_by', 'published_by', 'updated_at', + ] + list_filter = ['kind', 'status'] search_fields = ['title', 'filename', 'department__name'] - raw_id_fields = ['department', 'created_by'] - readonly_fields = ['created_at', 'updated_at'] + raw_id_fields = ['department', 'created_by', 'published_by'] + readonly_fields = ['created_at', 'updated_at', 'published_at'] diff --git a/apps/reports/documents.py b/apps/reports/documents.py index a46b8cb..ae6bfb2 100644 --- a/apps/reports/documents.py +++ b/apps/reports/documents.py @@ -1,6 +1,7 @@ """Документы отдела на /reports/: доступ, путь в Nextcloud, создание файлов.""" from __future__ import annotations +from django.utils import timezone from django.utils.text import slugify from apps.nextcloud_connector.access import safe_filename @@ -14,6 +15,7 @@ from apps.reports.registry import visible_reports from core.portal_access import user_can_access REPORTS_DAV_ROOT = '06_Отчёты' +ALLOWED_UPLOAD_EXTS = frozenset({'docx', 'xlsx'}) def user_can_open_reports_hub(user) -> bool: @@ -29,11 +31,15 @@ def user_can_view_report_document(user, doc: ReportDocument) -> bool: return False if user_can_view_all_department_reports(user): return True - return getattr(user, 'department_id', None) == doc.department_id + if getattr(user, 'department_id', None) != doc.department_id: + return False + if user_can_manage_it_monthly_report(user): + return True + return doc.is_published def user_can_edit_report_document(user, doc: ReportDocument) -> bool: - if not user_can_view_report_document(user, doc): + if not user_can_open_reports_hub(user): return False if user_can_view_all_department_reports(user): return True @@ -50,16 +56,24 @@ def user_can_create_report_document(user) -> bool: return user_can_manage_it_monthly_report(user) or user_can_view_all_department_reports(user) +def user_can_manage_report_documents(user) -> bool: + """Руководитель / view_all — создавать, грузить, публиковать.""" + return user_can_create_report_document(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') + qs = ReportDocument.objects.select_related('department', 'created_by', 'published_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) + qs = qs.filter(department_id=dept_id) + if user_can_manage_it_monthly_report(user): + return qs + return qs.filter(status=ReportDocument.STATUS_PUBLISHED) def report_document_relpath(doc: ReportDocument) -> str: @@ -78,3 +92,46 @@ def unique_filename(department_id: int, title: str, ext: str) -> str: if not ReportDocument.objects.filter(department_id=department_id, filename=name).exists(): return name n += 1 + + +def unique_upload_filename(department_id: int, original_name: str) -> str | None: + """Сохранить исходное имя файла, при коллизии — суффикс -2, -3…""" + name = safe_filename(original_name) + if not name: + return None + if '.' in name: + stem, ext = name.rsplit('.', 1) + ext = ext.lower() + else: + stem, ext = name, 'docx' + if ext not in ALLOWED_UPLOAD_EXTS: + return None + candidate = safe_filename(f'{stem}.{ext}') or f'document.{ext}' + if not ReportDocument.objects.filter(department_id=department_id, filename=candidate).exists(): + return candidate + n = 2 + while True: + cand = safe_filename(f'{stem}-{n}.{ext}') or f'document-{n}.{ext}' + if not ReportDocument.objects.filter(department_id=department_id, filename=cand).exists(): + return cand + n += 1 + + +def publish_report_document(user, doc: ReportDocument) -> ReportDocument: + if not user_can_edit_report_document(user, doc): + raise PermissionError('Недостаточно прав для публикации.') + doc.status = ReportDocument.STATUS_PUBLISHED + doc.published_at = timezone.now() + doc.published_by = user + doc.save(update_fields=['status', 'published_at', 'published_by', 'updated_at']) + return doc + + +def unpublish_report_document(user, doc: ReportDocument) -> ReportDocument: + if not user_can_edit_report_document(user, doc): + raise PermissionError('Недостаточно прав для снятия с публикации.') + doc.status = ReportDocument.STATUS_DRAFT + doc.published_at = None + doc.published_by = None + doc.save(update_fields=['status', 'published_at', 'published_by', 'updated_at']) + return doc diff --git a/apps/reports/migrations/0006_reportdocument_publish.py b/apps/reports/migrations/0006_reportdocument_publish.py new file mode 100644 index 0000000..cfb6d48 --- /dev/null +++ b/apps/reports/migrations/0006_reportdocument_publish.py @@ -0,0 +1,56 @@ +# Generated manually for ReportDocument publish + upload support + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('reports', '0005_report_document'), + ] + + operations = [ + migrations.AddField( + model_name='reportdocument', + name='status', + field=models.CharField( + choices=[('draft', 'Черновик'), ('published', 'Опубликован')], + db_index=True, + # Existing documents stay visible to department members. + default='published', + max_length=16, + verbose_name='Статус', + ), + ), + migrations.AddField( + model_name='reportdocument', + name='published_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Опубликован'), + ), + migrations.AddField( + model_name='reportdocument', + name='published_by', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='report_documents_published', + to=settings.AUTH_USER_MODEL, + verbose_name='Кто опубликовал', + ), + ), + migrations.AlterField( + model_name='reportdocument', + name='status', + field=models.CharField( + choices=[('draft', 'Черновик'), ('published', 'Опубликован')], + db_index=True, + default='draft', + max_length=16, + verbose_name='Статус', + ), + ), + ] diff --git a/apps/reports/models.py b/apps/reports/models.py index 490512c..31e7c8a 100644 --- a/apps/reports/models.py +++ b/apps/reports/models.py @@ -162,6 +162,13 @@ class ReportDocument(models.Model): (KIND_SNAPSHOT, _('Снимок отчёта')), ] + STATUS_DRAFT = 'draft' + STATUS_PUBLISHED = 'published' + STATUS_CHOICES = [ + (STATUS_DRAFT, _('Черновик')), + (STATUS_PUBLISHED, _('Опубликован')), + ] + department = models.ForeignKey( 'core.Directory', on_delete=models.CASCADE, @@ -171,6 +178,10 @@ class ReportDocument(models.Model): title = models.CharField(_('Название'), max_length=200) filename = models.CharField(_('Имя файла'), max_length=220) kind = models.CharField(_('Тип'), max_length=16, choices=KIND_CHOICES, default=KIND_EXTRA) + status = models.CharField( + _('Статус'), max_length=16, choices=STATUS_CHOICES, + default=STATUS_DRAFT, db_index=True, + ) source_month = models.CharField(_('Период отчёта'), max_length=7, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -182,6 +193,15 @@ class ReportDocument(models.Model): related_name='report_documents_created', verbose_name=_('Кто создал'), ) + published_at = models.DateTimeField(_('Опубликован'), null=True, blank=True) + published_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='report_documents_published', + verbose_name=_('Кто опубликовал'), + ) class Meta: verbose_name = _('Документ отчётов') @@ -197,3 +217,7 @@ class ReportDocument(models.Model): def __str__(self): return self.title + @property + def is_published(self) -> bool: + return self.status == self.STATUS_PUBLISHED + diff --git a/apps/reports/tests.py b/apps/reports/tests.py index ac0a6fd..49bb2bd 100644 --- a/apps/reports/tests.py +++ b/apps/reports/tests.py @@ -598,4 +598,48 @@ class ReportDocumentTests(TestCase): doc = ReportDocument.objects.get(title='План работ') self.assertTrue(doc.filename.endswith('.docx')) self.assertEqual(doc.kind, ReportDocument.KIND_EXTRA) + self.assertEqual(doc.status, ReportDocument.STATUS_DRAFT) self.assertIn('/reports/', r['Location']) + + def test_head_upload_docx_and_publish_for_member(self): + from unittest.mock import patch + from django.core.files.uploadedfile import SimpleUploadedFile + from apps.reports.models import ReportDocument + + login_2fa(self.client, self.head) + fake = SimpleUploadedFile( + 'Отчёт.docx', + b'PK\x03\x04fake-docx', + content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ) + with patch('apps.reports.views._store_report_file', return_value=None): + r = self.client.post( + '/reports/documents/upload/', + {'title': 'Месячный обзор', 'file': fake}, + ) + self.assertEqual(r.status_code, 302) + doc = ReportDocument.objects.get(title='Месячный обзор') + self.assertEqual(doc.status, ReportDocument.STATUS_DRAFT) + self.assertTrue(doc.filename.endswith('.docx')) + + login_2fa(self.client, self.member) + hub = self.client.get('/reports/') + self.assertNotContains(hub, 'Месячный обзор') + + login_2fa(self.client, self.head) + pub = self.client.post(f'/reports/documents/{doc.pk}/publish/') + self.assertEqual(pub.status_code, 302) + doc.refresh_from_db() + self.assertTrue(doc.is_published) + + login_2fa(self.client, self.member) + hub = self.client.get('/reports/') + self.assertContains(hub, 'Месячный обзор') + + def test_member_cannot_upload(self): + from django.core.files.uploadedfile import SimpleUploadedFile + + login_2fa(self.client, self.member) + fake = SimpleUploadedFile('x.docx', b'x') + r = self.client.post('/reports/documents/upload/', {'file': fake}) + self.assertEqual(r.status_code, 403) diff --git a/apps/reports/urls.py b/apps/reports/urls.py index 6f1afb1..6863690 100644 --- a/apps/reports/urls.py +++ b/apps/reports/urls.py @@ -38,11 +38,26 @@ urlpatterns = [ views.report_document_create_view, name='report_document_create', ), + path( + 'reports/documents/upload/', + views.report_document_upload_view, + name='report_document_upload', + ), path( 'reports/documents//download/', views.report_document_download_view, name='report_document_download', ), + path( + 'reports/documents//publish/', + views.report_document_publish_view, + name='report_document_publish', + ), + path( + 'reports/documents//unpublish/', + views.report_document_unpublish_view, + name='report_document_unpublish', + ), path( 'reports/documents//delete/', views.report_document_delete_view, diff --git a/apps/reports/views.py b/apps/reports/views.py index 305b9ff..c844e19 100644 --- a/apps/reports/views.py +++ b/apps/reports/views.py @@ -15,8 +15,11 @@ from django.views.decorators.http import require_POST from apps.reports.access import user_can_edit_template, user_can_manage_templates from apps.reports.engine import TemplateRenderError, render_report from apps.reports.documents import ( + publish_report_document, report_document_relpath, unique_filename, + unique_upload_filename, + unpublish_report_document, user_can_create_report_document, user_can_edit_report_document, user_can_view_report_document, @@ -259,6 +262,7 @@ def report_document_create_view(request): title=title, filename=filename, kind=ReportDocument.KIND_EXTRA, + status=ReportDocument.STATUS_DRAFT, created_by=request.user, ) payload = empty_xlsx() if ext == 'xlsx' else empty_docx() @@ -274,7 +278,83 @@ def report_document_create_view(request): return HttpResponseRedirect('/reports/') if _onlyoffice_ready(): return HttpResponseRedirect(f'/documents/edit/?report_doc={doc.pk}') - request.session['reports_message'] = f'Документ «{title}» создан. OnlyOffice пока не настроен — откройте файл позже.' + request.session['reports_message'] = f'Документ «{title}» создан (черновик). OnlyOffice пока не настроен — откройте файл позже.' + return HttpResponseRedirect('/reports/') + + +@login_required +@require_POST +def report_document_upload_view(request): + """Загрузка готового Word/Excel в документы отдела (черновик).""" + if not user_can_create_report_document(request.user): + return HttpResponseForbidden() + f = request.FILES.get('file') + if not f: + request.session['reports_error'] = 'Выберите файл Word (.docx) или Excel (.xlsx).' + return HttpResponseRedirect('/reports/') + + from apps.nextcloud_connector.views import MAX_UPLOAD_BYTES + + if f.size > MAX_UPLOAD_BYTES: + request.session['reports_error'] = 'Файл слишком большой (лимит 100 МБ).' + return HttpResponseRedirect('/reports/') + + filename = unique_upload_filename(request.user.department_id, f.name) + if not filename: + request.session['reports_error'] = 'Допустимы только файлы .docx и .xlsx.' + return HttpResponseRedirect('/reports/') + + title = (request.POST.get('title') or '').strip() + if not title: + title = filename.rsplit('.', 1)[0].replace('-', ' ').replace('_', ' ').strip() or filename + + ext = filename.rsplit('.', 1)[-1].lower() + ctype = ( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + if ext == 'xlsx' + else 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ) + content = f.read() + doc = ReportDocument.objects.create( + department_id=request.user.department_id, + title=title[:200], + filename=filename, + kind=ReportDocument.KIND_EXTRA, + status=ReportDocument.STATUS_DRAFT, + created_by=request.user, + ) + err = _store_report_file(report_document_relpath(doc), content, ctype) + if err: + doc.delete() + request.session['reports_error'] = err + return HttpResponseRedirect('/reports/') + request.session['reports_message'] = ( + f'Файл «{doc.title}» загружен как черновик. Опубликуйте, чтобы отдел его увидел.' + ) + return HttpResponseRedirect('/reports/') + + +@login_required +@require_POST +def report_document_publish_view(request, pk): + doc = get_object_or_404(ReportDocument, pk=pk) + try: + publish_report_document(request.user, doc) + except PermissionError: + return HttpResponseForbidden() + request.session['reports_message'] = f'Документ «{doc.title}» опубликован.' + return HttpResponseRedirect('/reports/') + + +@login_required +@require_POST +def report_document_unpublish_view(request, pk): + doc = get_object_or_404(ReportDocument, pk=pk) + try: + unpublish_report_document(request.user, doc) + except PermissionError: + return HttpResponseForbidden() + request.session['reports_message'] = f'Документ «{doc.title}» снят с публикации.' return HttpResponseRedirect('/reports/') @@ -338,6 +418,7 @@ def it_monthly_save_document_view(request, month_id): title=title, filename=filename, kind=ReportDocument.KIND_SNAPSHOT, + status=ReportDocument.STATUS_DRAFT, source_month=report['id'], created_by=request.user, ) @@ -352,7 +433,7 @@ def it_monthly_save_document_view(request, month_id): 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}».' + request.session['reports_message'] = f'Отчёт сохранён в документы (черновик): «{title}».' return HttpResponseRedirect('/reports/') diff --git a/templates/pages/reports.html b/templates/pages/reports.html index 0fcf25b..42d928d 100644 --- a/templates/pages/reports.html +++ b/templates/pages/reports.html @@ -83,16 +83,32 @@ {{ doc.department.name }} {{ doc.updated_at|date:"d.m.Y H:i" }} - {% if doc.filename|slice:"-5:" == ".xlsx" %} - Excel + {% if doc.is_published %} + {% trans "Опубликован" %} {% else %} - Word + {% trans "Черновик" %} + {% endif %} + {% if doc.filename|slice:"-5:" == ".xlsx" %} + Excel + {% else %} + Word {% endif %} {% trans "Скачать" %} {% if can_create_report_document %} + {% if doc.is_published %} +
+ {% csrf_token %} + +
+ {% else %} +
+ {% csrf_token %} + +
+ {% endif %}
{% csrf_token %} @@ -111,6 +127,7 @@ {% if can_create_report_document %} +
{% csrf_token %} @@ -123,6 +140,21 @@ +
+ {% csrf_token %} + + + +
+
+
+ {% trans "Новые файлы — черновики. Опубликуйте, чтобы сотрудники отдела их увидели." %} +
{% endif %}