03019898b0
Добавлена офлайн/EndUser-подпись «ознакомлен и принял в работу» для документов отчётов и файловые вложения к задачам через Nextcloud. Co-authored-by: Cursor <cursoragent@cursor.com>
310 lines
12 KiB
Python
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}]'
|
|
|