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 <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 15:00:27 +03:00
parent 31eecb5001
commit 555dbfb157
8 changed files with 325 additions and 13 deletions
+7 -4
View File
@@ -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']
+61 -4
View File
@@ -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
@@ -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='Статус',
),
),
]
+24
View File
@@ -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
+44
View File
@@ -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)
+15
View File
@@ -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/<int:pk>/download/',
views.report_document_download_view,
name='report_document_download',
),
path(
'reports/documents/<int:pk>/publish/',
views.report_document_publish_view,
name='report_document_publish',
),
path(
'reports/documents/<int:pk>/unpublish/',
views.report_document_unpublish_view,
name='report_document_unpublish',
),
path(
'reports/documents/<int:pk>/delete/',
views.report_document_delete_view,
+83 -2
View File
@@ -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/')