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:
@@ -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']
|
||||
|
||||
|
||||
@@ -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='Статус',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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/')
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user