Files
ACORE/apps/reports/views.py
T
lokses 03019898b0 reports: КЕП-подпись PDF отчётов; задачи: загрузка вложений.
Добавлена офлайн/EndUser-подпись «ознакомлен и принял в работу» для документов отчётов и файловые вложения к задачам через Nextcloud.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-06 21:13:28 +03:00

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