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

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

938 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Тесты раздела Отчёты и месячного IT-отчёта."""
from datetime import date
import json
from django.contrib.auth.models import Group
from django.test import Client, TestCase
from apps.it_manager.models import WorkLog
from apps.it_manager.tests import _dir_id, make_employee
from apps.locations.models.pharmacies import Pharmacy
from apps.reports.it_monthly import (
build_monthly_report, report_to_markdown, user_can_view_it_monthly_report,
)
from apps.reports.it_monthly.builder import split_work_items
from apps.reports.it_monthly.conclusions import build_conclusions
from apps.reports.registry import visible_reports
from core.models.directories import Directory, DirectoryType
from core.test_utils import login_2fa, grant_all_locations
class MonthlyReportTests(TestCase):
def setUp(self):
dept_type, _ = DirectoryType.objects.get_or_create(
code='department', defaults={'name': 'Відділи', 'hierarchy_type': 'strict'},
)
self.dept = Directory.objects.create(
directory_type=dept_type, code='it-rep', name='IT-відділ-rep',
)
self.head = make_employee(
last_name='Бабич', first_name='Виктор', username='babich.rep',
department=self.dept, user_location_type='office',
)
self.it_user = make_employee(
last_name='Апродов', first_name='Артур', username='apr.rep',
department=self.dept, manager=self.head, user_location_type='office',
)
it_group = Group.objects.get(name='IT')
self.head.groups.add(it_group)
grant_all_locations(self.head)
self.it_user.groups.add(it_group)
grant_all_locations(self.it_user)
self.client = Client()
region_type, _ = DirectoryType.objects.get_or_create(code='region', defaults={'name': 'Регион'})
org_type, _ = DirectoryType.objects.get_or_create(code='organization', defaults={'name': 'Организация'})
region = Directory.objects.create(directory_type=region_type, code='od-rep', name='Одесса-rep')
org = Directory.objects.create(
directory_type=org_type, code='akc-rep', name='АКС-rep',
extra_data={'mail_prefix': 'a'},
)
pharmacy = Pharmacy.objects.create(
organization=org, location_region=region,
pharmacy_number=35, location_type='pharmacy',
street='Реп', house_number='2',
)
WorkLog.objects.create(
date=date(2026, 5, 3),
technician=self.it_user,
location=pharmacy,
work_type_id=_dir_id('worklog_type', 'software'),
description='Восстановление ПРРО на кассе',
)
WorkLog.objects.create(
date=date(2026, 5, 4),
technician=self.it_user,
work_type_id=_dir_id('worklog_type', 'network'),
description='Диагностика локальной сети',
)
def test_only_department_head_can_view(self):
self.assertTrue(user_can_view_it_monthly_report(self.head))
self.assertFalse(user_can_view_it_monthly_report(self.it_user))
self.assertEqual([s.code for s in visible_reports(self.head)], ['it_monthly'])
self.assertEqual(visible_reports(self.it_user), [])
def test_head_without_all_locations_still_manages_report(self):
"""Руководитель отдела не обязан иметь view_all_locations для IT-отчёта."""
from django.contrib.auth.models import Permission
from core.location_access import has_all_locations
perm = Permission.objects.filter(
content_type__app_label='locations', codename='view_all_locations',
).first()
if perm:
self.head.user_permissions.remove(perm)
for group in self.head.groups.all():
group.permissions.remove(perm)
self.head = type(self.head).objects.get(pk=self.head.pk)
self.assertFalse(has_all_locations(self.head))
self.assertTrue(user_can_view_it_monthly_report(self.head))
login_2fa(self.client, self.head)
page = self.client.get('/reports/it/monthly/?month=2026-05')
self.assertEqual(page.status_code, 200)
self.assertContains(page, 'Сформировать Word')
self.assertEqual(self.client.get('/reports/it/monthly/2026-05.docx').status_code, 200)
def test_published_visible_to_department_and_view_all(self):
from django.contrib.auth.models import Permission
from apps.reports.models import ReportDocument, ReportPublication
from apps.reports.publication import publish_it_monthly
self.assertFalse(user_can_view_it_monthly_report(self.it_user))
publish_it_monthly(self.head, 2026, 5)
# Месяц опубликован, но без Word-снимка — сотрудник видит карточку, не live-контент.
ReportDocument.objects.create(
department=self.dept,
title='IT-отчёт Май 2026',
filename='it-may.docx',
kind=ReportDocument.KIND_SNAPSHOT,
status=ReportDocument.STATUS_PUBLISHED,
source_month='2026-05',
created_by=self.head,
published_by=self.head,
)
self.assertTrue(user_can_view_it_monthly_report(self.it_user))
self.assertEqual([s.code for s in visible_reports(self.it_user)], ['it_monthly'])
login_2fa(self.client, self.it_user)
page = self.client.get('/reports/it/monthly/?month=2026-05')
self.assertEqual(page.status_code, 200)
self.assertContains(page, 'IT-отчёт Май 2026')
self.assertContains(page, 'Скачать Word')
self.assertNotContains(page, 'Восстановление ПРРО')
self.assertNotContains(page, 'Сформировать Word')
# неопубликованный месяц в URL → показывают последний опубликованный
switched = self.client.get('/reports/it/monthly/?month=2026-04')
self.assertEqual(switched.status_code, 200)
self.assertContains(switched, '2026-05')
self.assertEqual(self.client.get('/reports/it/monthly/2026-04.md').status_code, 403)
other_dept_type, _ = DirectoryType.objects.get_or_create(
code='department', defaults={'name': 'Відділи', 'hierarchy_type': 'strict'},
)
other_dept = Directory.objects.create(
directory_type=other_dept_type, code='sales-rep', name='Sales-rep',
)
outsider = make_employee(
last_name='Чужой', first_name='Юзер', username='outsider.rep',
department=other_dept, user_location_type='office',
)
self.assertFalse(user_can_view_it_monthly_report(outsider))
viewer = make_employee(
last_name='Все', first_name='Отделы', username='all.depts',
department=other_dept, user_location_type='office',
)
perm = Permission.objects.get(codename='view_all_departments', content_type__app_label='reports')
viewer.user_permissions.add(perm)
self.assertTrue(user_can_view_it_monthly_report(viewer))
login_2fa(self.client, viewer)
self.assertEqual(self.client.get('/reports/it/monthly/?month=2026-05').status_code, 200)
# Живой PDF только руководителю
self.assertEqual(self.client.get('/reports/it/monthly/2026-05.pdf').status_code, 403)
def test_head_prepare_word_then_publish_document(self):
from unittest.mock import patch
from apps.reports.models import ReportDocument, ReportPublication
from apps.reports.documents import publish_report_document
login_2fa(self.client, self.head)
page = self.client.get('/reports/it/monthly/?month=2026-05')
self.assertContains(page, 'Сформировать Word')
self.assertContains(page, 'Черновик')
self.assertNotContains(page, 'Опубликовать')
with patch('apps.reports.views._store_report_file', return_value=None):
prep = self.client.post('/reports/it/monthly/2026-05/publish/')
self.assertEqual(prep.status_code, 302)
doc = ReportDocument.objects.get(source_month='2026-05', kind=ReportDocument.KIND_SNAPSHOT)
self.assertEqual(doc.status, ReportDocument.STATUS_DRAFT)
self.assertFalse(
ReportPublication.objects.filter(
department=self.dept, year=2026, month=5,
status=ReportPublication.STATUS_PUBLISHED,
).exists()
)
publish_report_document(self.head, doc)
doc.refresh_from_db()
self.assertTrue(doc.is_published)
self.assertTrue(
ReportPublication.objects.filter(
department=self.dept, year=2026, month=5,
status=ReportPublication.STATUS_PUBLISHED,
).exists()
)
self.assertTrue(user_can_view_it_monthly_report(self.it_user))
login_2fa(self.client, self.head)
unpub = self.client.post('/reports/it/monthly/2026-05/unpublish/')
self.assertEqual(unpub.status_code, 302)
doc.refresh_from_db()
self.assertEqual(doc.status, ReportDocument.STATUS_DRAFT)
self.assertFalse(user_can_view_it_monthly_report(self.it_user))
def test_staff_roster_head_first_then_last_name_and_zone(self):
from apps.reports.it_monthly.docx import report_as_month
self.it_user.it_responsibility_zone = 'Зона Апродова тест'
self.it_user.save(update_fields=['it_responsibility_zone'])
# Extra colleague alphabetically before Апродов but after head
make_employee(
last_name='Яковенко', first_name='Игорь', username='yak.rep',
department=self.dept, manager=self.head, user_location_type='office',
it_responsibility_zone='Зона Яковенко',
)
make_employee(
last_name='Борисов', first_name='Пётр', username='bor.rep',
department=self.dept, manager=self.head, user_location_type='office',
it_responsibility_zone='Зона Борисова',
)
company = {
'staff': [{
'id': 'aprodov', 'name': 'Апродов Артур', 'short': 'Апродов А.',
'role': 'L1', 'match': ['апродов'], 'zone': 'из json',
}],
'directions': [],
}
report = build_monthly_report(2026, 5, company=company)
roster = report['staff_roster']
self.assertGreaterEqual(len(roster), 3)
self.assertEqual(roster[0]['employee_id'], self.head.pk)
names = [r['name'] for r in roster[1:]]
# After head: Борисов, Апродов (from logs may appear), Яковенко — by last_name
last_names = []
for r in roster[1:]:
# extract last name from full name or employee
last_names.append(r['name'].split()[0])
self.assertEqual(last_names, sorted(last_names, key=lambda s: s.casefold()))
aprodov_row = next(r for r in roster if r.get('employee_id') == self.it_user.pk)
self.assertEqual(aprodov_row['zone'], 'Зона Апродова тест')
month = report_as_month(report, company)
self.assertEqual(month['employees'][0]['name'].split()[0], 'Бабич')
apro_month = next(
e for e in month['employees']
if e.get('id') == 'aprodov' or 'Апродов' in (e.get('name') or '')
)
self.assertEqual(apro_month['zone'], 'Зона Апродова тест')
def test_build_report(self):
company = {
'staff': [{
'id': 'aprodov', 'name': 'Апродов Артур', 'short': 'Апродов А.',
'role': 'L1', 'match': ['апродов'],
}],
'directions': [
{'id': 'prro', 'name': 'ПРРО', 'keywords': ['прро']},
{'id': 'network', 'name': 'Сеть', 'keywords': ['сет']},
],
}
report = build_monthly_report(2026, 5, company=company)
self.assertEqual(report['total_tasks'], 2)
self.assertEqual(report['people_active'], 1)
self.assertIn('А35', report['pharmacies'])
self.assertEqual(report['employees'][0]['tasks_count'], 2)
self.assertTrue(report['conclusions'])
self.assertTrue(any('ежедневном режиме' in (c.get('text') or '') for c in report['conclusions']))
md = report_to_markdown(report)
self.assertIn('Восстановление ПРРО', md)
self.assertIn('## Сводка по направлениям', md)
self.assertIn('Основные выводы и рекомендации', md)
items = report['employees'][0]['tasks'][0]['work_items']
self.assertEqual(items, ['Восстановление ПРРО на кассе'])
def test_conclusions_cover_roles(self):
company = {
'staff': [
{'id': 'aprodov', 'name': 'Апродов Артур', 'short': 'Апродов А.', 'match': ['апродов']},
{'id': 'kogutenko', 'name': 'Когутенко Сергей', 'short': 'Когутенко С.', 'match': ['когутенко']},
{'id': 'khizhniak', 'name': 'Хижняк Николай', 'short': 'Хижняк Н.', 'match': ['хижняк']},
{'id': 'babich', 'name': 'Бабич Виктор', 'short': 'Бабич В.И.', 'match': ['бабич']},
],
'directions': [
{'id': 'prro', 'name': 'ПРРО', 'keywords': ['прро']},
{'id': 'network', 'name': 'Сеть', 'keywords': ['mikrotik', 'роутер']},
],
}
report = {
'month': 7,
'total_tasks': 100,
'summary': [{'name': 'Обслуживание кассового ПО (ПРРО)', 'count': 40, 'people': 'Апродов А.'}],
'employees': [
{
'id': 'aprodov', 'name': 'Апродов Артур', 'tasks_count': 50, 'systems_count': 0,
'tasks': [{'object': 'А10', 'work': 'Восстановление ПРРО'}], 'systems': [],
},
{
'id': 'kogutenko', 'name': 'Когутенко Сергей', 'tasks_count': 30, 'systems_count': 0,
'tasks': [
{'object': 'А12', 'work': 'Замена роутера MikroTik'},
{'object': 'А15', 'work': 'Монтаж оборудования'},
],
'systems': [],
},
{
'id': 'khizhniak', 'name': 'Хижняк Николай', 'tasks_count': 20, 'systems_count': 0,
'tasks': [{'object': 'ОФИС', 'work': 'Подготовка ПК сотруднику; обслуживание МФУ'}],
'systems': [],
},
{
'id': 'babich', 'name': 'Бабич', 'tasks_count': 0, 'systems_count': 2,
'tasks': [],
'systems': [{'name': 'Proxmox', 'work': 'Мониторинг'}],
},
],
}
conclusions = build_conclusions(report, company)
text = '\n'.join(c['text'] for c in conclusions)
self.assertIn('Артура Апродова', text)
self.assertIn('Сергея Когутенко', text)
self.assertIn('Николаем Хижняком', text)
self.assertIn('Proxmox', text)
last = conclusions[-1]
self.assertTrue(last.get('bullets'))
self.assertTrue(any('удаленно' in b for b in last['bullets']))
def test_pharmacy_object_is_not_field_visit_for_remote_or_unknown(self):
"""Удалёнка и неизвестные сотрудники с объектом «А35» не должны попадать в «с выездом»."""
from apps.reports.it_monthly.conclusions import resolve_work_bucket
self.assertEqual(
resolve_work_bucket(
'aprodov',
{'role': 'Специалист удалённой поддержки', 'tasks': [{'object': 'А35'}]},
{},
),
'remote',
)
# Поле карточки сотрудника важнее STAFF_PHRASES / объекта аптеки
self.assertEqual(
resolve_work_bucket(
'kogutenko',
{'it_work_channel': 'remote', 'tasks': [{'object': 'А12'}]},
{},
),
'remote',
)
company = {
'staff': [
{
'id': 'new_remote',
'name': 'Тестов Удалённый',
'role': 'Специалист удалённой поддержки',
'bucket': 'remote',
},
],
}
self.assertEqual(
resolve_work_bucket('new_remote', {'role': '', 'tasks': []}, company),
'remote',
)
# unmatched: аптека → remote, не field
self.assertEqual(
resolve_work_bucket(
'user-99',
{'role': '', 'tasks': [{'object': 'А35', 'work': 'ПРРО'}], 'tasks_count': 1},
company,
),
'unknown',
)
report = {
'month': 8,
'total_tasks': 40,
'summary': [{'name': 'ПРРО', 'count': 40, 'people': 'X'}],
'employees': [
{
'id': 'aprodov',
'name': 'Апродов',
'role': 'Специалист удалённой поддержки',
'it_work_channel': 'remote',
'tasks_count': 20,
'systems_count': 0,
'tasks': [{'object': 'А35', 'work': 'ПРРО'}, {'object': 'Т2', 'work': 'Терминал'}],
'systems': [],
},
{
'id': 'user-42',
'name': 'Удалённый сотрудник',
'role': '',
'it_work_channel': 'remote',
'tasks_count': 20,
'systems_count': 0,
'tasks': [{'object': 'А10', 'work': 'Helpdesk'}],
'systems': [],
},
],
}
conclusions = build_conclusions(report, company)
bullets = conclusions[-1].get('bullets') or []
field_line = next(b for b in bullets if 'выезд' in b)
remote_line = next(b for b in bullets if 'удален' in b)
# все 40 задач — remote; field должен быть ~0%
self.assertIn('~0%', field_line)
self.assertIn('~100%', remote_line)
def test_split_semicolon_work_items(self):
items = split_work_items('восстановление печати на МФУ; добавление баз; настройка сканера')
self.assertEqual(len(items), 3)
self.assertEqual(items[0], 'Восстановление печати на МФУ')
def test_pharmacy_object_codes_all_org_letters(self):
from apps.reports.it_monthly.builder import _is_pharmacy_object_code
for code in ('А37', 'A37', 'Т2', 'T2', 'П10', 'Г3', 'Л1'):
self.assertTrue(_is_pharmacy_object_code(code), code)
for code in ('ОФИС', 'Склад', 'Вайтер Юг', '№5', ''):
self.assertFalse(_is_pharmacy_object_code(code), code)
def test_reports_hub_and_it_monthly_for_head(self):
login_2fa(self.client, self.head)
hub = self.client.get('/reports/')
self.assertEqual(hub.status_code, 200)
self.assertContains(hub, 'Месячный IT-отчёт')
self.assertContains(hub, '/reports/it/monthly/')
self.assertContains(hub, 'IT-отдел')
self.assertContains(hub, 'Открыть')
page = self.client.get('/reports/it/monthly/?month=2026-05')
self.assertEqual(page.status_code, 200)
self.assertContains(page, 'Восстановление ПРРО')
self.assertContains(page, 'Скачать Word')
self.assertContains(page, 'Скачать PDF')
it_page = self.client.get('/it-manager/')
self.assertEqual(it_page.status_code, 200)
self.assertContains(it_page, '/reports/it/monthly/')
self.assertNotContains(it_page, 'id="it-panel-report"')
md = self.client.get('/reports/it/monthly/2026-05.md')
self.assertEqual(md.status_code, 200)
self.assertIn('attachment', md['Content-Disposition'])
self.assertIn('Восстановление ПРРО', md.content.decode())
docx = self.client.get('/reports/it/monthly/2026-05.docx')
self.assertEqual(docx.status_code, 200)
self.assertTrue(docx.content.startswith(b'PK'))
pdf = self.client.get('/reports/it/monthly/2026-05.pdf')
self.assertEqual(pdf.status_code, 200)
self.assertTrue(pdf.content.startswith(b'%PDF'))
preview = self.client.get('/reports/it/monthly/2026-05/preview/')
self.assertEqual(preview.status_code, 200)
self.assertContains(preview, 'ОТЧЁТ О ВЫПОЛНЕННЫХ РАБОТАХ')
self.assertEqual(preview.get('X-Frame-Options'), 'SAMEORIGIN')
def test_legacy_urls_redirect(self):
login_2fa(self.client, self.head)
for fmt, path in [
('md', '/it-manager/report/2026-05.md'),
('docx', '/it-manager/report/2026-05.docx'),
('pdf', '/it-manager/report/2026-05.pdf'),
]:
r = self.client.get(path)
self.assertEqual(r.status_code, 302, path)
self.assertEqual(r['Location'], f'/reports/it/monthly/2026-05.{fmt}')
r = self.client.get('/it-manager/report/2026-05/preview/')
self.assertEqual(r.status_code, 302)
self.assertEqual(r['Location'], '/reports/it/monthly/2026-05/preview/')
def test_report_forbidden_for_regular_it(self):
login_2fa(self.client, self.it_user)
r = self.client.get('/it-manager/')
self.assertEqual(r.status_code, 200)
self.assertNotContains(r, '/reports/it/monthly/')
self.assertEqual(self.client.get('/reports/it/monthly/').status_code, 403)
self.assertEqual(self.client.get('/reports/it/monthly/2026-05.md').status_code, 403)
self.assertEqual(self.client.get('/reports/it/monthly/2026-05.docx').status_code, 403)
self.assertEqual(self.client.get('/reports/it/monthly/2026-05.pdf').status_code, 403)
self.assertEqual(self.client.get('/reports/it/monthly/2026-05/preview/').status_code, 403)
class ReportTemplateTests(TestCase):
def setUp(self):
from apps.reports.classic_seed import CLASSIC_BLOCKS, CLASSIC_HTML, CLASSIC_MD
from apps.reports.models import ReportTemplate
dept_type, _ = DirectoryType.objects.get_or_create(
code='department', defaults={'name': 'Відділи', 'hierarchy_type': 'strict'},
)
self.dept = Directory.objects.create(
directory_type=dept_type, code='it-tpl', name='IT-відділ-tpl',
)
self.head = make_employee(
last_name='Бабич', first_name='Виктор', username='babich.tpl',
department=self.dept, user_location_type='office',
)
self.it_user = make_employee(
last_name='Апродов', first_name='Артур', username='apr.tpl',
department=self.dept, manager=self.head, user_location_type='office',
)
it_group = Group.objects.get(name='IT')
self.head.groups.add(it_group)
grant_all_locations(self.head)
self.it_user.groups.add(it_group)
grant_all_locations(self.it_user)
self.client = Client()
self.classic, _ = ReportTemplate.objects.update_or_create(
code='it_monthly_classic',
defaults={
'report_type': ReportTemplate.REPORT_IT_MONTHLY,
'name': 'Классический IT',
'engine': ReportTemplate.ENGINE_BLOCKS_V1,
'body_html': CLASSIC_HTML,
'body_md': CLASSIC_MD,
'blocks': CLASSIC_BLOCKS,
'is_default': True,
'is_active': True,
'is_system': True,
},
)
WorkLog.objects.create(
date=date(2026, 5, 3),
technician=self.it_user,
work_type_id=_dir_id('worklog_type', 'software'),
description='Восстановление ПРРО на кассе',
)
def test_classic_render_html(self):
from apps.reports.engine import render_report
report = build_monthly_report(2026, 5, company={'staff': [], 'directions': []})
html = render_report(report, self.classic, 'html')
self.assertIn('ОТЧЁТ О ВЫПОЛНЕННЫХ РАБОТАХ', html)
self.assertIn('Восстановление ПРРО', html)
def test_blocks_v1_hides_section(self):
from apps.reports.engine import render_report
from apps.reports.models import ReportTemplate
tpl = ReportTemplate.objects.create(
code='it-no-staff',
report_type=ReportTemplate.REPORT_IT_MONTHLY,
name='Без состава',
engine=ReportTemplate.ENGINE_BLOCKS_V1,
blocks={
'labels': {'main_title': 'КОРОТКИЙ', 'summary_heading': 'Сводка'},
'sections': ['stats', 'summary', 'signature'],
},
is_system=False,
)
report = build_monthly_report(2026, 5, company={'staff': [], 'directions': []})
html = render_report(report, tpl, 'html')
self.assertIn('КОРОТКИЙ', html)
self.assertIn('Сводка', html)
self.assertNotIn('Состав IT-отдела', html)
self.assertNotIn('Выполненные работы по сотрудникам', html)
def test_custom_title_in_template(self):
from apps.reports.engine import render_report
from apps.reports.models import ReportTemplate
custom = ReportTemplate.objects.create(
code='it-custom-title',
report_type=ReportTemplate.REPORT_IT_MONTHLY,
name='Свой заголовок',
engine=ReportTemplate.ENGINE_DJANGO_HTML,
body_html='<h1>{{ labels.main_title }}</h1><p>{{ report.total_tasks }}</p>',
body_md='# {{ labels.main_title }}',
blocks={'labels': {'main_title': 'КАСТОМНЫЙ ОТЧЁТ IT'}},
is_system=False,
)
report = build_monthly_report(2026, 5, company={'staff': [], 'directions': []})
html = render_report(report, custom, 'html')
self.assertIn('КАСТОМНЫЙ ОТЧЁТ IT', html)
def test_invalid_template_returns_400(self):
from apps.reports.engine import TemplateRenderError, render_report
from apps.reports.models import ReportTemplate
bad = ReportTemplate.objects.create(
code='it-bad',
report_type=ReportTemplate.REPORT_IT_MONTHLY,
name='Bad',
engine=ReportTemplate.ENGINE_DJANGO_HTML,
body_html='{% invalid_tag %}',
is_system=False,
is_default=True,
)
self.classic.is_default = False
self.classic.save(update_fields=['is_default'])
report = build_monthly_report(2026, 5, company={'staff': [], 'directions': []})
with self.assertRaises(TemplateRenderError):
render_report(report, bad, 'html')
login_2fa(self.client, self.head)
r = self.client.get('/reports/it/monthly/2026-05/preview/')
self.assertEqual(r.status_code, 400)
def test_head_can_open_editor_and_copy(self):
login_2fa(self.client, self.head)
r = self.client.get('/reports/templates/')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'it_monthly_classic')
r = self.client.post(f'/reports/templates/{self.classic.pk}/copy/')
self.assertEqual(r.status_code, 302)
from apps.reports.models import ReportTemplate
copy = ReportTemplate.objects.exclude(pk=self.classic.pk).get()
self.assertFalse(copy.is_system)
self.assertIn('копия', copy.name.lower())
self.assertEqual(copy.engine, ReportTemplate.ENGINE_BLOCKS_V1)
r = self.client.get(f'/reports/templates/{copy.pk}/')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'Конструктор')
self.assertContains(r, 'Секции отчёта')
# системный нельзя сохранить через POST
r = self.client.post(f'/reports/templates/{self.classic.pk}/', {
'name': 'Hacked', 'description': '', 'body_html': 'x', 'body_md': '',
'blocks': '{}', 'engine': 'blocks_v1',
})
self.assertEqual(r.status_code, 403)
self.classic.refresh_from_db()
self.assertNotEqual(self.classic.name, 'Hacked')
# копию можно сохранить через конструктор
r = self.client.post(f'/reports/templates/{copy.pk}/', {
'name': copy.name,
'description': '',
'engine': 'blocks_v1',
'body_html': '',
'body_md': '',
'blocks': json.dumps({
'labels': {'main_title': 'МОЙ ОТЧЁТ'},
'sections': ['stats', 'summary', 'signature'],
}),
'is_active': 'on',
})
self.assertEqual(r.status_code, 200)
copy.refresh_from_db()
self.assertEqual(copy.blocks['labels']['main_title'], 'МОЙ ОТЧЁТ')
self.assertEqual(copy.blocks['sections'], ['stats', 'summary', 'signature'])
def test_regular_it_forbidden_templates(self):
login_2fa(self.client, self.it_user)
self.assertEqual(self.client.get('/reports/templates/').status_code, 403)
def test_preview_draft_renders_and_rejects_bad_syntax(self):
login_2fa(self.client, self.head)
url = f'/reports/templates/{self.classic.pk}/preview-draft/'
ok = self.client.post(url, {
'engine': 'blocks_v1',
'body_html': '',
'blocks': json.dumps({
'labels': {'main_title': 'ПРЕВЬЮ'},
'sections': ['stats', 'signature'],
}),
'month': '2026-05',
})
self.assertEqual(ok.status_code, 200)
self.assertIn('ПРЕВЬЮ', ok.content.decode())
bad = self.client.post(url, {
'engine': 'django_html',
'body_html': '{% broken %}',
'blocks': '{}',
'month': '2026-05',
})
self.assertEqual(bad.status_code, 400)
self.assertIn('Ошибка', bad.content.decode())
class ReportDocumentTests(TestCase):
def setUp(self):
dept_type, _ = DirectoryType.objects.get_or_create(
code='department', defaults={'name': 'Відділи', 'hierarchy_type': 'strict'},
)
self.dept = Directory.objects.create(
directory_type=dept_type, code='it-docs', name='IT-docs',
)
self.head = make_employee(
last_name='Глава', first_name='Отдел', username='head.docs',
department=self.dept, user_location_type='office',
)
self.member = make_employee(
last_name='Сотр', first_name='Отдел', username='mem.docs',
department=self.dept, manager=self.head, user_location_type='office',
)
it_group = Group.objects.get(name='IT')
self.head.groups.add(it_group)
grant_all_locations(self.head)
self.member.groups.add(it_group)
grant_all_locations(self.member)
self.client = Client()
def test_hub_shows_documents_block_for_head(self):
login_2fa(self.client, self.head)
r = self.client.get('/reports/')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'Документы отдела')
self.assertContains(r, 'Создать')
def test_member_cannot_create(self):
login_2fa(self.client, self.member)
r = self.client.post('/reports/documents/create/', {'title': 'План', 'file_type': 'docx'})
self.assertEqual(r.status_code, 403)
def test_head_creates_docx_when_storage_configured(self):
from unittest.mock import patch
from apps.reports.models import ReportDocument
login_2fa(self.client, self.head)
with patch('apps.reports.views._store_report_file', return_value=None):
r = self.client.post('/reports/documents/create/', {'title': 'План работ', 'file_type': 'docx'})
self.assertEqual(r.status_code, 302)
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)
from apps.reports.documents import visible_report_documents
titles = set(visible_report_documents(self.member).values_list('title', flat=True))
self.assertNotIn('Месячный обзор', titles)
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)
titles = set(visible_report_documents(self.member).values_list('title', flat=True))
self.assertIn('Месячный обзор', titles)
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)
def test_view_all_sees_only_published(self):
from django.contrib.auth.models import Permission
from apps.reports.documents import visible_report_documents
from apps.reports.models import ReportDocument
other_dept_type, _ = DirectoryType.objects.get_or_create(
code='department', defaults={'name': 'Відділи', 'hierarchy_type': 'strict'},
)
other = Directory.objects.create(
directory_type=other_dept_type, code='hr-docs', name='HR-docs',
)
draft = ReportDocument.objects.create(
department=other, title='Черновик HR', filename='draft.docx',
kind=ReportDocument.KIND_EXTRA, status=ReportDocument.STATUS_DRAFT,
created_by=self.head,
)
published = ReportDocument.objects.create(
department=other, title='Опубликован HR', filename='pub.docx',
kind=ReportDocument.KIND_EXTRA, status=ReportDocument.STATUS_PUBLISHED,
created_by=self.head,
)
viewer = make_employee(
last_name='Все', first_name='Отделы', username='view.all.docs',
department=self.dept, user_location_type='office',
)
it_group = Group.objects.get(name='IT')
viewer.groups.add(it_group)
grant_all_locations(viewer)
perm = Permission.objects.get(
codename='view_all_departments', content_type__app_label='reports',
)
viewer.user_permissions.add(perm)
titles = set(visible_report_documents(viewer).values_list('title', flat=True))
self.assertIn(published.title, titles)
self.assertNotIn(draft.title, titles)
class ReportKepSignatureTests(TestCase):
"""КЕП: prepare PDF, complete upload, download signed."""
def setUp(self):
dept_type, _ = DirectoryType.objects.get_or_create(
code='department', defaults={'name': 'Відділи', 'hierarchy_type': 'strict'},
)
self.dept = Directory.objects.create(
directory_type=dept_type, code='it-kep', name='IT-kep',
)
self.head = make_employee(
last_name='Кеп', first_name='Глава', username='kep.head',
department=self.dept, user_location_type='office',
)
self.member = make_employee(
last_name='Кеп', first_name='Сотр', username='kep.mem',
department=self.dept, manager=self.head, user_location_type='office',
)
it_group = Group.objects.get(name='IT')
self.head.groups.add(it_group)
grant_all_locations(self.head)
self.member.groups.add(it_group)
grant_all_locations(self.member)
self.client = Client()
from apps.reports.models import ReportDocument
self.doc = ReportDocument.objects.create(
department=self.dept,
title='План на месяц',
filename='plan.docx',
kind=ReportDocument.KIND_EXTRA,
status=ReportDocument.STATUS_PUBLISHED,
created_by=self.head,
published_by=self.head,
)
def test_acknowledgment_pdf_contains_title(self):
from apps.reports.signing import _acknowledgment_pdf
pdf = _acknowledgment_pdf(self.doc, 'a' * 64, 'plan.docx')
self.assertTrue(pdf.startswith(b'%PDF'))
# fpdf embeds text; title may be in compressed streams — just check size
self.assertGreater(len(pdf), 500)
def test_prepare_returns_pdf_and_sha_header(self):
from unittest.mock import patch
login_2fa(self.client, self.member)
with patch('apps.nextcloud_connector.webdav_service.NextcloudWebDAVService') as MockDAV:
MockDAV.return_value.download.return_value = (b'PK\x03\x04docx', 'application/octet-stream')
r = self.client.get(f'/reports/documents/{self.doc.pk}/sign/prepare/')
self.assertEqual(r.status_code, 200)
self.assertEqual(r['Content-Type'], 'application/pdf')
self.assertTrue(r.content.startswith(b'%PDF'))
self.assertEqual(len(r['X-Content-SHA256']), 64)
def test_complete_stores_signature(self):
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from apps.reports.models import ReportSignature
from apps.reports.signing import _acknowledgment_pdf, sha256_hex
pdf = _acknowledgment_pdf(self.doc, 'b' * 64, 'plan.docx')
digest = sha256_hex(pdf)
login_2fa(self.client, self.member)
signed = SimpleUploadedFile('signed.pdf', pdf, content_type='application/pdf')
with patch('apps.nextcloud_connector.webdav_service.NextcloudWebDAVService') as MockDAV:
MockDAV.return_value.upload.return_value = None
r = self.client.post(
f'/reports/documents/{self.doc.pk}/sign/complete/',
{
'file': signed,
'offline': '1',
'content_sha256': digest,
'cert_subject': 'Кеп Сотр',
'cert_serial': 'ABC123',
},
)
self.assertEqual(r.status_code, 302)
self.assertIn('/sign/', r['Location'])
sig = ReportSignature.objects.get(document=self.doc, status=ReportSignature.STATUS_SIGNED)
self.assertEqual(sig.cert_subject, 'Кеп Сотр')
self.assertEqual(sig.cert_serial, 'ABC123')
self.assertEqual(sig.signer, self.member)
self.assertTrue(sig.signed_filename.endswith('.pdf'))
self.assertTrue(self.doc.is_signed)
def test_outsider_cannot_sign(self):
from django.core.files.uploadedfile import SimpleUploadedFile
outsider = make_employee(
last_name='Чуж', first_name='Ой', username='kep.out',
user_location_type='office',
)
it_group = Group.objects.get(name='IT')
outsider.groups.add(it_group)
grant_all_locations(outsider)
login_2fa(self.client, outsider)
r = self.client.get(f'/reports/documents/{self.doc.pk}/sign/')
self.assertEqual(r.status_code, 403)
fake = SimpleUploadedFile('x.pdf', b'%PDF-1.4', content_type='application/pdf')
r2 = self.client.post(
f'/reports/documents/{self.doc.pk}/sign/complete/',
{'file': fake, 'offline': '1'},
)
self.assertEqual(r2.status_code, 403)
def test_sign_page_and_hub_link(self):
login_2fa(self.client, self.member)
r = self.client.get(f'/reports/documents/{self.doc.pk}/sign/')
self.assertEqual(r.status_code, 200)
self.assertContains(r, 'Подпись КЕП')
self.assertContains(r, 'Ознакомлен и принял в работу')
hub = self.client.get('/reports/')
self.assertContains(hub, 'Подписать КЕП')
def test_download_signed(self):
from unittest.mock import patch
from apps.reports.models import ReportSignature
sig = ReportSignature.objects.create(
document=self.doc,
signer=self.member,
status=ReportSignature.STATUS_SIGNED,
signed_filename='plan-kep-signed.pdf',
content_sha256='c' * 64,
cert_subject='Кеп Сотр',
)
login_2fa(self.client, self.member)
with patch('apps.nextcloud_connector.webdav_service.NextcloudWebDAVService') as MockDAV:
MockDAV.return_value.download.return_value = (b'%PDF-signed', 'application/pdf')
r = self.client.get(
f'/reports/documents/{self.doc.pk}/sign/{sig.pk}/download/',
)
self.assertEqual(r.status_code, 200)
self.assertEqual(r.content, b'%PDF-signed')