03019898b0
Добавлена офлайн/EndUser-подпись «ознакомлен и принял в работу» для документов отчётов и файловые вложения к задачам через Nextcloud. Co-authored-by: Cursor <cursoragent@cursor.com>
855 lines
33 KiB
Python
855 lines
33 KiB
Python
"""Views раздела «Отчёты»."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
|
|
from django.contrib.auth.decorators import login_required
|
|
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
|
|
|
|
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_open_reports_hub,
|
|
user_can_view_report_document,
|
|
visible_report_documents,
|
|
)
|
|
from apps.reports.it_monthly import (
|
|
available_report_months,
|
|
build_monthly_report,
|
|
parse_month_id,
|
|
user_can_manage_it_monthly_report,
|
|
user_can_view_it_monthly_report,
|
|
)
|
|
from apps.reports.models import ReportDocument, ReportSignature, ReportTemplate
|
|
from apps.reports.publication import (
|
|
available_months_for_user,
|
|
publication_for_user_month,
|
|
published_snapshot_for_month,
|
|
report_list_rows,
|
|
unpublish_month_and_snapshots,
|
|
user_can_access_it_monthly_month,
|
|
)
|
|
from apps.reports.registry import visible_reports
|
|
from apps.reports.signing import (
|
|
build_unsigned_pdf,
|
|
complete_signature,
|
|
parse_cert_meta,
|
|
sha256_hex,
|
|
signed_file_relpath,
|
|
)
|
|
from core.portal_access import deny_section_access
|
|
from core.utils.htmx import render_page
|
|
|
|
_SLUG_RE = re.compile(r'^[a-z0-9]+(?:-[a-z0-9]+)*$')
|
|
|
|
|
|
def _can_open_reports_hub(user) -> bool:
|
|
return user_can_open_reports_hub(user)
|
|
|
|
|
|
def _default_it_template() -> ReportTemplate | None:
|
|
"""Шаблон по умолчанию для IT (без выбора в UI отчёта)."""
|
|
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)
|
|
year, month = parse_month_id(month_raw)
|
|
mid = f'{year:04d}-{month:02d}'
|
|
if user_can_manage_it_monthly_report(request.user):
|
|
return year, month
|
|
if not months:
|
|
return HttpResponseForbidden()
|
|
if mid not in months:
|
|
year, month = parse_month_id(months[0])
|
|
if not user_can_access_it_monthly_month(request.user, year, month):
|
|
return HttpResponseForbidden()
|
|
return year, month
|
|
|
|
|
|
def _it_monthly_page_context(request, year: int, month: int) -> dict:
|
|
from apps.reports.it_monthly.builder import user_can_view_all_department_reports
|
|
from apps.reports.models import ReportDocument, ReportPublication
|
|
from apps.reports.publication import is_month_published
|
|
|
|
can_manage = user_can_manage_it_monthly_report(request.user)
|
|
can_all = user_can_view_all_department_reports(request.user)
|
|
dept_id = getattr(request.user, 'department_id', None)
|
|
mid = f'{year:04d}-{month:02d}'
|
|
|
|
pub = publication_for_user_month(request.user, year, month) if dept_id else None
|
|
if pub and pub.is_published:
|
|
report_is_published = True
|
|
report_published_at = pub.published_at
|
|
elif can_all:
|
|
report_is_published = is_month_published(
|
|
ReportPublication.REPORT_IT_MONTHLY, year, month,
|
|
)
|
|
report_published_at = None
|
|
else:
|
|
report_is_published = False
|
|
report_published_at = None
|
|
|
|
published_doc = None
|
|
if dept_id:
|
|
published_doc = published_snapshot_for_month(
|
|
department_id=dept_id, year=year, month=month,
|
|
)
|
|
if published_doc is None and can_all:
|
|
published_doc = (
|
|
ReportDocument.objects
|
|
.filter(
|
|
kind=ReportDocument.KIND_SNAPSHOT,
|
|
source_month=mid,
|
|
status=ReportDocument.STATUS_PUBLISHED,
|
|
)
|
|
.order_by('-published_at', '-id')
|
|
.first()
|
|
)
|
|
|
|
show_live = can_manage
|
|
kep_signature = published_doc.latest_signature if published_doc else None
|
|
context = {
|
|
'can_manage_templates': user_can_manage_templates(request.user),
|
|
'can_publish_report': can_manage,
|
|
'can_prepare_word': can_manage and user_can_create_report_document(request.user),
|
|
'report_is_published': report_is_published,
|
|
'report_published_at': report_published_at,
|
|
'published_document': published_doc,
|
|
'kep_signature': kep_signature,
|
|
'show_live_report': show_live,
|
|
'onlyoffice_ready': _onlyoffice_ready(),
|
|
}
|
|
if show_live:
|
|
report = build_monthly_report(year, month)
|
|
report['available_months'] = available_months_for_user(request.user) or [report['id']]
|
|
context['report'] = report
|
|
else:
|
|
title = published_doc.title if published_doc else mid
|
|
context['report'] = {
|
|
'id': mid,
|
|
'title': title,
|
|
'period_from': '',
|
|
'period_to': '',
|
|
'available_months': available_months_for_user(request.user) or [mid],
|
|
'total_tasks': 0,
|
|
'people_active': 0,
|
|
'objects_count': 0,
|
|
'pharmacies': [],
|
|
'other_points': [],
|
|
'summary': [],
|
|
'employees': [],
|
|
'conclusions': [],
|
|
}
|
|
return context
|
|
|
|
|
|
@login_required
|
|
def reports_hub_view(request):
|
|
if not _can_open_reports_hub(request.user):
|
|
return deny_section_access(request)
|
|
specs = visible_reports(request.user)
|
|
docs = list(
|
|
visible_report_documents(request.user)
|
|
.prefetch_related('signatures__signer')
|
|
)
|
|
for doc in docs:
|
|
doc.kep_signature = doc.latest_signature
|
|
return render_page(request, 'reports.html', {
|
|
'report_specs': specs,
|
|
'report_rows': report_list_rows(request.user),
|
|
'report_documents': docs,
|
|
'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),
|
|
})
|
|
|
|
|
|
@login_required
|
|
def it_monthly_view(request):
|
|
if not user_can_view_it_monthly_report(request.user):
|
|
return HttpResponseForbidden()
|
|
resolved = _resolve_it_month(
|
|
request, request.GET.get('month') or request.GET.get('report_month'),
|
|
)
|
|
if isinstance(resolved, HttpResponse):
|
|
return resolved
|
|
year, month = resolved
|
|
return render_page(request, 'reports-it-monthly.html', {
|
|
**_it_monthly_page_context(request, year, month),
|
|
'reports_message': request.session.pop('reports_message', ''),
|
|
'reports_error': request.session.pop('reports_error', ''),
|
|
})
|
|
|
|
|
|
def _export_with_template(request, month_id, fmt: str):
|
|
# Живой экспорт — только руководителю; остальные скачивают опубликованный Word.
|
|
if not user_can_manage_it_monthly_report(request.user):
|
|
return HttpResponseForbidden()
|
|
if not user_can_view_it_monthly_report(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, fmt)
|
|
except TemplateRenderError as exc:
|
|
return HttpResponseBadRequest(f'Ошибка шаблона: {exc}')
|
|
if fmt == 'md':
|
|
response = HttpResponse(payload, content_type='text/markdown; charset=utf-8')
|
|
response['Content-Disposition'] = f'attachment; filename="it-report-{report["id"]}.md"'
|
|
return response
|
|
if fmt == 'docx':
|
|
response = HttpResponse(
|
|
payload,
|
|
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
)
|
|
response['Content-Disposition'] = f'attachment; filename="IT-report-{report["id"]}.docx"'
|
|
return response
|
|
if fmt == 'pdf':
|
|
response = HttpResponse(payload, content_type='application/pdf')
|
|
response['Content-Disposition'] = f'attachment; filename="IT-report-{report["id"]}.pdf"'
|
|
return response
|
|
return HttpResponseBadRequest('Неизвестный формат')
|
|
|
|
|
|
@login_required
|
|
def it_monthly_md_view(request, month_id):
|
|
return _export_with_template(request, month_id, 'md')
|
|
|
|
|
|
@login_required
|
|
def it_monthly_docx_view(request, month_id):
|
|
return _export_with_template(request, month_id, 'docx')
|
|
|
|
|
|
@login_required
|
|
def it_monthly_pdf_view(request, month_id):
|
|
return _export_with_template(request, month_id, 'pdf')
|
|
|
|
|
|
@login_required
|
|
@xframe_options_sameorigin
|
|
def it_monthly_preview_view(request, month_id):
|
|
if not user_can_manage_it_monthly_report(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:
|
|
html = render_report(report, tpl, 'html')
|
|
except TemplateRenderError as exc:
|
|
return HttpResponse(
|
|
f'<html><body><p>Ошибка шаблона: {exc}</p></body></html>',
|
|
content_type='text/html; charset=utf-8',
|
|
status=400,
|
|
)
|
|
return HttpResponse(html, content_type='text/html; charset=utf-8')
|
|
|
|
|
|
def _prepare_it_monthly_word(request, year: int, month: int) -> HttpResponse:
|
|
"""Собрать Word-черновик снимка и открыть OnlyOffice (без публикации месяца)."""
|
|
if not user_can_create_report_document(request.user):
|
|
return HttpResponseForbidden()
|
|
if not user_can_manage_it_monthly_report(request.user):
|
|
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,
|
|
status=ReportDocument.STATUS_DRAFT,
|
|
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}')
|
|
request.session['reports_message'] = (
|
|
f'Word «{title}» создан как черновик. Поправьте файл и опубликуйте '
|
|
f'его в «Документах отдела» — тогда отчёт увидит отдел.'
|
|
)
|
|
if _onlyoffice_ready():
|
|
return HttpResponseRedirect(f'/documents/edit/?report_doc={doc.pk}')
|
|
return HttpResponseRedirect('/reports/')
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def it_monthly_publish_view(request, month_id):
|
|
"""«Сформировать Word» — черновик документа, без флага публикации месяца."""
|
|
if not user_can_manage_it_monthly_report(request.user):
|
|
return HttpResponseForbidden()
|
|
year, month = parse_month_id(month_id)
|
|
return _prepare_it_monthly_word(request, year, month)
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def it_monthly_unpublish_view(request, month_id):
|
|
if not user_can_manage_it_monthly_report(request.user):
|
|
return HttpResponseForbidden()
|
|
year, month = parse_month_id(month_id)
|
|
try:
|
|
unpublish_month_and_snapshots(request.user, year, month)
|
|
except (ValueError, PermissionError) as exc:
|
|
return HttpResponseForbidden(str(exc))
|
|
request.session['reports_message'] = 'Публикация месяца и Word-снимков снята.'
|
|
return HttpResponseRedirect(f'/reports/it/monthly/?month={year:04d}-{month:02d}')
|
|
|
|
|
|
def redirect_legacy_it_report(request, month_id, fmt: str):
|
|
if fmt == 'preview':
|
|
return HttpResponseRedirect(f'/reports/it/monthly/{month_id}/preview/')
|
|
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,
|
|
status=ReportDocument.STATUS_DRAFT,
|
|
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_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/')
|
|
|
|
|
|
@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):
|
|
"""Синоним «Сформировать Word» (совместимость со старым URL)."""
|
|
year, month = parse_month_id(month_id)
|
|
if not user_can_access_it_monthly_month(request.user, year, month):
|
|
return HttpResponseForbidden()
|
|
return _prepare_it_monthly_word(request, year, month)
|
|
|
|
|
|
# ── Шаблоны ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@login_required
|
|
def templates_list_view(request):
|
|
if not user_can_manage_templates(request.user):
|
|
return HttpResponseForbidden()
|
|
templates = ReportTemplate.objects.filter(
|
|
report_type=ReportTemplate.REPORT_IT_MONTHLY,
|
|
).order_by('-is_default', 'name')
|
|
return render_page(request, 'reports-templates.html', {
|
|
'templates': templates,
|
|
'can_manage_templates': True,
|
|
})
|
|
|
|
|
|
@login_required
|
|
def template_edit_view(request, pk):
|
|
if not user_can_manage_templates(request.user):
|
|
return HttpResponseForbidden()
|
|
tpl = get_object_or_404(ReportTemplate, pk=pk)
|
|
can_edit = user_can_edit_template(request.user, tpl)
|
|
error = ''
|
|
message = ''
|
|
|
|
if request.method == 'POST':
|
|
if not can_edit:
|
|
return HttpResponseForbidden('Системный шаблон можно только скопировать.')
|
|
name = request.POST.get('name', '').strip()
|
|
description = request.POST.get('description', '').strip()
|
|
body_html = request.POST.get('body_html', '')
|
|
body_md = request.POST.get('body_md', '')
|
|
blocks_raw = request.POST.get('blocks', '').strip()
|
|
engine = request.POST.get('engine', '').strip()
|
|
if engine not in (ReportTemplate.ENGINE_BLOCKS_V1, ReportTemplate.ENGINE_DJANGO_HTML):
|
|
engine = tpl.engine or ReportTemplate.ENGINE_BLOCKS_V1
|
|
is_default = bool(request.POST.get('is_default'))
|
|
is_active = request.POST.get('is_active') == 'on'
|
|
|
|
from apps.reports.engine.blocks_v1 import normalize_blocks
|
|
|
|
blocks = normalize_blocks(tpl.blocks)
|
|
if blocks_raw:
|
|
try:
|
|
parsed = json.loads(blocks_raw)
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError('JSON должен быть объектом {...}')
|
|
blocks = normalize_blocks(parsed)
|
|
except (json.JSONDecodeError, ValueError) as exc:
|
|
error = f'Некорректный JSON blocks: {exc}'
|
|
|
|
if not error:
|
|
if not name:
|
|
error = 'Укажите название.'
|
|
elif engine == ReportTemplate.ENGINE_DJANGO_HTML:
|
|
try:
|
|
from apps.reports.engine.django_tpl import render_django_string
|
|
render_django_string(body_html or ' ', {
|
|
'report': {}, 'labels': {}, 'company': {},
|
|
'month': {}, 'stats': {}, 'employees': [],
|
|
'conclusions': [], 'month_title': '',
|
|
'prepared_date': '',
|
|
})
|
|
except TemplateRenderError as exc:
|
|
error = f'Ошибка HTML-шаблона: {exc}'
|
|
|
|
if not error:
|
|
tpl.name = name
|
|
tpl.description = description
|
|
tpl.engine = engine
|
|
tpl.body_html = body_html
|
|
tpl.body_md = body_md
|
|
tpl.blocks = blocks
|
|
tpl.is_default = is_default
|
|
tpl.is_active = is_active
|
|
tpl.save()
|
|
message = 'Шаблон сохранён.'
|
|
can_edit = user_can_edit_template(request.user, tpl)
|
|
|
|
from apps.reports.engine.blocks_v1 import labels_for_ui, normalize_blocks, sections_for_ui
|
|
|
|
blocks_norm = normalize_blocks(tpl.blocks)
|
|
months = available_report_months()
|
|
return render_page(request, 'reports-template-edit.html', {
|
|
'tpl': tpl,
|
|
'can_edit': can_edit,
|
|
'blocks_json': json.dumps(blocks_norm, ensure_ascii=False, indent=2),
|
|
'label_fields': labels_for_ui(blocks_norm),
|
|
'section_rows': sections_for_ui(blocks_norm),
|
|
'editor_mode': (
|
|
'advanced' if tpl.engine == ReportTemplate.ENGINE_DJANGO_HTML else 'constructor'
|
|
),
|
|
'error': error,
|
|
'message': message,
|
|
'can_manage_templates': True,
|
|
'preview_months': months,
|
|
'preview_month': months[0] if months else '',
|
|
})
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def template_preview_draft_view(request, pk):
|
|
"""Рендер черновика без сохранения (конструктор или Ace)."""
|
|
if not user_can_manage_templates(request.user):
|
|
return HttpResponseForbidden()
|
|
get_object_or_404(ReportTemplate, pk=pk)
|
|
|
|
from apps.reports.engine.blocks_v1 import normalize_blocks, render_blocks_html
|
|
from apps.reports.engine.context import build_template_context, load_default_company
|
|
from apps.reports.engine.django_tpl import render_django_string as render_str
|
|
|
|
engine = (request.POST.get('engine') or ReportTemplate.ENGINE_BLOCKS_V1).strip()
|
|
body_html = request.POST.get('body_html', '')
|
|
blocks_raw = (request.POST.get('blocks') or '').strip()
|
|
blocks = normalize_blocks(None)
|
|
if blocks_raw:
|
|
try:
|
|
parsed = json.loads(blocks_raw)
|
|
if isinstance(parsed, dict):
|
|
blocks = normalize_blocks(parsed)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
year, month = parse_month_id(request.POST.get('month'))
|
|
report = build_monthly_report(year, month)
|
|
ctx = build_template_context(
|
|
report, company=load_default_company(), labels=blocks.get('labels'),
|
|
)
|
|
try:
|
|
if engine == ReportTemplate.ENGINE_DJANGO_HTML:
|
|
html = render_str(body_html or ' ', ctx)
|
|
else:
|
|
html = render_blocks_html(ctx, blocks)
|
|
except TemplateRenderError as exc:
|
|
return HttpResponse(
|
|
f'<html><body style="font-family:sans-serif;padding:1rem;color:#b91c1c;">'
|
|
f'<strong>Ошибка шаблона</strong><pre style="white-space:pre-wrap;">{exc}</pre>'
|
|
f'</body></html>',
|
|
content_type='text/html; charset=utf-8',
|
|
status=400,
|
|
)
|
|
return HttpResponse(html, content_type='text/html; charset=utf-8')
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def template_copy_view(request, pk):
|
|
if not user_can_manage_templates(request.user):
|
|
return HttpResponseForbidden()
|
|
src = get_object_or_404(ReportTemplate, pk=pk)
|
|
base = f'{src.code}-copy'
|
|
code = base
|
|
n = 2
|
|
while ReportTemplate.objects.filter(code=code).exists():
|
|
code = f'{base}-{n}'
|
|
n += 1
|
|
if not _SLUG_RE.match(code):
|
|
code = f'copy-{src.pk}-{n}'
|
|
|
|
from apps.reports.engine.blocks_v1 import normalize_blocks
|
|
|
|
copy = ReportTemplate.objects.create(
|
|
code=code,
|
|
report_type=src.report_type,
|
|
name=f'{src.name} (копия)',
|
|
description=src.description,
|
|
# копия всегда в конструкторе — проще править
|
|
engine=ReportTemplate.ENGINE_BLOCKS_V1,
|
|
body_html=src.body_html,
|
|
body_md=src.body_md,
|
|
blocks=normalize_blocks(src.blocks),
|
|
is_default=False,
|
|
is_active=True,
|
|
is_system=False,
|
|
created_by=request.user,
|
|
)
|
|
return HttpResponseRedirect(f'/reports/templates/{copy.pk}/')
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def template_set_default_view(request, pk):
|
|
if not user_can_manage_templates(request.user):
|
|
return HttpResponseForbidden()
|
|
tpl = get_object_or_404(ReportTemplate, pk=pk, is_active=True)
|
|
tpl.is_default = True
|
|
tpl.save()
|
|
return HttpResponseRedirect('/reports/templates/')
|
|
|
|
|
|
@login_required
|
|
def report_document_sign_view(request, pk):
|
|
"""Страница КЕП: скачать PDF для подписи и загрузить подписанный файл."""
|
|
doc = get_object_or_404(ReportDocument, pk=pk)
|
|
if not user_can_view_report_document(request.user, doc):
|
|
return HttpResponseForbidden()
|
|
latest = doc.latest_signature
|
|
history = list(
|
|
doc.signatures
|
|
.filter(status=ReportSignature.STATUS_SIGNED)
|
|
.select_related('signer')
|
|
.order_by('-signed_at')[:10]
|
|
)
|
|
return render_page(request, 'report-sign.html', {
|
|
'document': doc,
|
|
'kep_signature': latest,
|
|
'kep_history': history,
|
|
'sign_message': request.session.pop('reports_message', ''),
|
|
'sign_error': request.session.pop('reports_error', ''),
|
|
})
|
|
|
|
|
|
@login_required
|
|
def report_document_sign_prepare_view(request, pk):
|
|
"""Скачать исходный PDF для подписи КЕП + sha256 в заголовке."""
|
|
doc = get_object_or_404(ReportDocument, pk=pk)
|
|
if not user_can_view_report_document(request.user, doc):
|
|
return HttpResponseForbidden()
|
|
try:
|
|
pdf, _note = build_unsigned_pdf(doc)
|
|
except RuntimeError as exc:
|
|
return HttpResponseBadRequest(str(exc))
|
|
except Exception:
|
|
return HttpResponseBadRequest('Не удалось подготовить PDF для подписи.')
|
|
|
|
digest = sha256_hex(pdf)
|
|
# Запомним хеш в сессии — complete сверит при желании.
|
|
request.session[f'report_sign_sha_{doc.pk}'] = digest
|
|
|
|
from urllib.parse import quote
|
|
|
|
resp = HttpResponse(pdf, content_type='application/pdf')
|
|
name = f'{doc.title[:80] or "report"}-for-sign.pdf'
|
|
resp['Content-Disposition'] = f"attachment; filename*=UTF-8''{quote(name)}"
|
|
resp['X-Content-SHA256'] = digest
|
|
resp['X-Content-Type-Options'] = 'nosniff'
|
|
return resp
|
|
|
|
|
|
@login_required
|
|
@require_POST
|
|
def report_document_sign_complete_view(request, pk):
|
|
"""Принять подписанный PDF (браузер EndUser или офлайн-загрузка)."""
|
|
doc = get_object_or_404(ReportDocument, pk=pk)
|
|
if not user_can_view_report_document(request.user, doc):
|
|
return HttpResponseForbidden()
|
|
|
|
f = request.FILES.get('file') or request.FILES.get('signed_file')
|
|
if not f:
|
|
request.session['reports_error'] = 'Выберите подписанный PDF-файл.'
|
|
return HttpResponseRedirect(f'/reports/documents/{pk}/sign/')
|
|
|
|
from apps.nextcloud_connector.views import MAX_UPLOAD_BYTES
|
|
|
|
if f.size > MAX_UPLOAD_BYTES:
|
|
request.session['reports_error'] = 'Файл слишком большой (лимит 100 МБ).'
|
|
return HttpResponseRedirect(f'/reports/documents/{pk}/sign/')
|
|
|
|
content = f.read()
|
|
meta = parse_cert_meta(request.POST.get('cert_meta'))
|
|
if request.POST.get('cert_subject'):
|
|
meta['cert_subject'] = request.POST.get('cert_subject', '')[:500]
|
|
if request.POST.get('cert_serial'):
|
|
meta['cert_serial'] = request.POST.get('cert_serial', '')[:120]
|
|
if request.POST.get('cert_issuer'):
|
|
meta['cert_issuer'] = request.POST.get('cert_issuer', '')[:500]
|
|
|
|
content_sha = (request.POST.get('content_sha256') or '').strip().lower()
|
|
session_sha = (request.session.get(f'report_sign_sha_{doc.pk}') or '').lower()
|
|
if not content_sha and session_sha:
|
|
content_sha = session_sha
|
|
|
|
offline = request.POST.get('offline') == '1' or not request.POST.get('cert_meta')
|
|
|
|
try:
|
|
sig = complete_signature(
|
|
document=doc,
|
|
user=request.user,
|
|
signed_pdf=content,
|
|
content_sha256=content_sha,
|
|
cert_subject=meta.get('cert_subject', ''),
|
|
cert_serial=meta.get('cert_serial', ''),
|
|
cert_issuer=meta.get('cert_issuer', ''),
|
|
signature_format=meta.get('signature_format') or ReportSignature.FORMAT_PADES,
|
|
offline_upload=offline,
|
|
)
|
|
except ValueError as exc:
|
|
request.session['reports_error'] = str(exc)
|
|
return HttpResponseRedirect(f'/reports/documents/{pk}/sign/')
|
|
except RuntimeError as exc:
|
|
request.session['reports_error'] = str(exc)
|
|
return HttpResponseRedirect(f'/reports/documents/{pk}/sign/')
|
|
|
|
request.session.pop(f'report_sign_sha_{doc.pk}', None)
|
|
who = sig.cert_subject or (request.user.get_full_name() or request.user.username)
|
|
request.session['reports_message'] = (
|
|
f'Документ подписан КЕП ({who}). Назначение: ознакомлен и принял в работу.'
|
|
)
|
|
return HttpResponseRedirect(f'/reports/documents/{pk}/sign/')
|
|
|
|
|
|
@login_required
|
|
def report_document_signed_download_view(request, pk, sig_pk):
|
|
doc = get_object_or_404(ReportDocument, pk=pk)
|
|
if not user_can_view_report_document(request.user, doc):
|
|
return HttpResponseForbidden()
|
|
sig = get_object_or_404(ReportSignature, pk=sig_pk, document=doc)
|
|
if sig.status != ReportSignature.STATUS_SIGNED or not sig.signed_filename:
|
|
raise Http404('Подписанный файл недоступен.')
|
|
|
|
from urllib.parse import quote
|
|
|
|
from apps.nextcloud_connector.nextcloud_service import NextcloudConfigError, NextcloudServiceError
|
|
from apps.nextcloud_connector.webdav_service import NextcloudWebDAVService
|
|
|
|
try:
|
|
content, ctype = NextcloudWebDAVService().download(
|
|
signed_file_relpath(doc, sig.signed_filename),
|
|
)
|
|
except (NextcloudConfigError, NextcloudServiceError):
|
|
raise Http404('Файл недоступен.')
|
|
|
|
resp = HttpResponse(content, content_type=ctype or 'application/pdf')
|
|
resp['Content-Disposition'] = (
|
|
f"attachment; filename*=UTF-8''{quote(sig.signed_filename)}"
|
|
)
|
|
resp['X-Content-Type-Options'] = 'nosniff'
|
|
return resp
|