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

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

310 lines
12 KiB
Python

"""Шаблоны оформления и публикация отчётов."""
from __future__ import annotations
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
REPORT_IT_MONTHLY = 'it_monthly'
REPORT_TYPE_CHOICES = [
(REPORT_IT_MONTHLY, 'Месячный IT-отчёт'),
]
class ReportTemplate(models.Model):
ENGINE_DJANGO_HTML = 'django_html'
ENGINE_BLOCKS_V1 = 'blocks_v1'
ENGINE_CHOICES = [
(ENGINE_DJANGO_HTML, 'Django HTML / Markdown'),
(ENGINE_BLOCKS_V1, 'Блоки (заголовки секций)'),
]
REPORT_IT_MONTHLY = REPORT_IT_MONTHLY
REPORT_TYPE_CHOICES = REPORT_TYPE_CHOICES
code = models.SlugField(_('Код'), max_length=64, unique=True)
report_type = models.CharField(
_('Тип отчёта'), max_length=32, choices=REPORT_TYPE_CHOICES, db_index=True,
)
name = models.CharField(_('Название'), max_length=200)
description = models.TextField(_('Описание'), blank=True)
engine = models.CharField(
_('Движок'), max_length=32, choices=ENGINE_CHOICES, default=ENGINE_DJANGO_HTML,
)
# Django-шаблон для HTML-превью
body_html = models.TextField(_('Шаблон HTML'), blank=True)
# Django-шаблон для Markdown-экспорта
body_md = models.TextField(_('Шаблон Markdown'), blank=True)
# labels / sections для blocks_v1 и подписей в DOCX/PDF
blocks = models.JSONField(_('Блоки / подписи'), default=dict, blank=True)
is_default = models.BooleanField(_('По умолчанию'), default=False)
is_active = models.BooleanField(_('Активен'), default=True)
is_system = models.BooleanField(
_('Системный'), default=False,
help_text=_('Системный шаблон нельзя править обычным пользователям — только копировать.'),
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, blank=True, related_name='report_templates_created',
)
class Meta:
verbose_name = _('Шаблон отчёта')
verbose_name_plural = _('Шаблоны отчётов')
ordering = ['report_type', '-is_default', 'name']
def __str__(self):
return f'{self.name} ({self.code})'
def clean(self):
if self.engine == self.ENGINE_DJANGO_HTML and not (self.body_html or '').strip():
raise ValidationError({'body_html': _('Для Django HTML нужен шаблон HTML.')})
if self.engine == self.ENGINE_BLOCKS_V1 and not self.blocks:
raise ValidationError({'blocks': _('Для блочного движка заполните JSON blocks.')})
def save(self, *args, **kwargs):
if self.is_default:
type(self).objects.filter(
report_type=self.report_type, is_default=True,
).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)
@classmethod
def resolve(cls, report_type: str, code: str | None = None) -> ReportTemplate | None:
qs = cls.objects.filter(report_type=report_type, is_active=True)
if code:
found = qs.filter(code=code).first()
if found:
return found
return qs.filter(is_default=True).first() or qs.first()
class ReportPublication(models.Model):
"""Публикация отчёта отделом: до публикации видит только руководитель."""
STATUS_DRAFT = 'draft'
STATUS_PUBLISHED = 'published'
STATUS_CHOICES = [
(STATUS_DRAFT, _('Черновик')),
(STATUS_PUBLISHED, _('Опубликован')),
]
REPORT_IT_MONTHLY = REPORT_IT_MONTHLY
REPORT_TYPE_CHOICES = REPORT_TYPE_CHOICES
report_type = models.CharField(
_('Тип отчёта'), max_length=32, choices=REPORT_TYPE_CHOICES, db_index=True,
)
department = models.ForeignKey(
'core.Directory',
on_delete=models.CASCADE,
related_name='report_publications',
verbose_name=_('Отдел'),
)
year = models.PositiveSmallIntegerField(_('Год'))
month = models.PositiveSmallIntegerField(_('Месяц'))
status = models.CharField(
_('Статус'), max_length=16, choices=STATUS_CHOICES, default=STATUS_DRAFT, db_index=True,
)
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_publications',
verbose_name=_('Кто опубликовал'),
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _('Публикация отчёта')
verbose_name_plural = _('Публикации отчётов')
ordering = ['-year', '-month', 'department_id']
constraints = [
models.UniqueConstraint(
fields=['report_type', 'department', 'year', 'month'],
name='reports_publication_unique_month',
),
models.CheckConstraint(
condition=models.Q(month__gte=1, month__lte=12),
name='reports_publication_month_range',
),
]
permissions = [
('view_all_departments', 'Видеть отчёты всех отделов'),
]
def __str__(self):
return f'{self.report_type} {self.year:04d}-{self.month:02d} ({self.department_id}) [{self.status}]'
@property
def month_id(self) -> str:
return f'{self.year:04d}-{self.month:02d}'
@property
def is_published(self) -> bool:
return self.status == self.STATUS_PUBLISHED
class ReportDocument(models.Model):
"""Файл отдела на странице отчётов: доп. документ или снимок сформированного отчёта."""
KIND_EXTRA = 'extra'
KIND_SNAPSHOT = 'snapshot'
KIND_CHOICES = [
(KIND_EXTRA, _('Документ')),
(KIND_SNAPSHOT, _('Снимок отчёта')),
]
STATUS_DRAFT = 'draft'
STATUS_PUBLISHED = 'published'
STATUS_CHOICES = [
(STATUS_DRAFT, _('Черновик')),
(STATUS_PUBLISHED, _('Опубликован')),
]
department = models.ForeignKey(
'core.Directory',
on_delete=models.CASCADE,
related_name='report_documents',
verbose_name=_('Отдел'),
)
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)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
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 = _('Документ отчётов')
verbose_name_plural = _('Документы отчётов')
ordering = ['-updated_at']
constraints = [
models.UniqueConstraint(
fields=['department', 'filename'],
name='reports_document_unique_filename',
),
]
def __str__(self):
return self.title
@property
def is_published(self) -> bool:
return self.status == self.STATUS_PUBLISHED
@property
def latest_signature(self):
"""Последняя успешная КЕП-подпись (ознакомление)."""
return (
self.signatures
.filter(status=ReportSignature.STATUS_SIGNED)
.select_related('signer')
.order_by('-signed_at', '-id')
.first()
)
@property
def is_signed(self) -> bool:
return self.signatures.filter(status=ReportSignature.STATUS_SIGNED).exists()
class ReportSignature(models.Model):
"""КЕП-подпись PDF отчёта: «ознакомлен и принял в работу»."""
PURPOSE_ACKNOWLEDGED = 'acknowledged'
PURPOSE_CHOICES = [
(PURPOSE_ACKNOWLEDGED, _('Ознакомлен и принял в работу')),
]
STATUS_PENDING = 'pending'
STATUS_SIGNED = 'signed'
STATUS_FAILED = 'failed'
STATUS_CHOICES = [
(STATUS_PENDING, _('Ожидает')),
(STATUS_SIGNED, _('Подписано')),
(STATUS_FAILED, _('Ошибка')),
]
FORMAT_PADES = 'pades'
FORMAT_CADES = 'cades_detached'
FORMAT_CHOICES = [
(FORMAT_PADES, 'PAdES (PDF)'),
(FORMAT_CADES, 'CAdES (отсоединённая)'),
]
document = models.ForeignKey(
ReportDocument,
on_delete=models.CASCADE,
related_name='signatures',
verbose_name=_('Документ'),
)
signer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='report_signatures',
verbose_name=_('Подписант'),
)
purpose = models.CharField(
_('Назначение'), max_length=32,
choices=PURPOSE_CHOICES, default=PURPOSE_ACKNOWLEDGED,
)
status = models.CharField(
_('Статус'), max_length=16,
choices=STATUS_CHOICES, default=STATUS_PENDING, db_index=True,
)
signed_at = models.DateTimeField(_('Подписано'), null=True, blank=True)
cert_subject = models.CharField(_('Субъект сертификата'), max_length=500, blank=True)
cert_serial = models.CharField(_('Серийный номер'), max_length=120, blank=True)
cert_issuer = models.CharField(_('Издатель'), max_length=500, blank=True)
signature_format = models.CharField(
_('Формат подписи'), max_length=32,
choices=FORMAT_CHOICES, default=FORMAT_PADES,
)
signed_filename = models.CharField(_('Имя подписанного файла'), max_length=220, blank=True)
content_sha256 = models.CharField(_('SHA-256 исходного PDF'), max_length=64, blank=True)
verify_ok = models.BooleanField(_('Проверка ОК'), null=True, blank=True)
verify_detail = models.TextField(_('Детали проверки'), blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _('Подпись отчёта (КЕП)')
verbose_name_plural = _('Подписи отчётов (КЕП)')
ordering = ['-signed_at', '-id']
def __str__(self):
who = self.cert_subject or (self.signer.get_full_name() if self.signer_id else '?')
return f'{self.document_id}: {who} [{self.status}]'