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