3ec5fb58c4
При утверждении найма сотрудник получает роли (группы) по умолчанию для своего типа локации (Аптека/Офис/Склад). Наборы настраиваются в EmployeeDefaults (админка «Настройки сотрудников по умолчанию»). - EmployeeDefaults: 3 M2M default_groups_* + apply_default_groups(); - approve_hiring_request вызывает apply_default_groups после создания Employee; - админка: блок «Роли по умолчанию при найме» (filter_horizontal); - миграция 0023; тесты найма. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
375 lines
16 KiB
Python
375 lines
16 KiB
Python
"""Тести найму: пул інтернів, IT Ticket, Employee лише після approve, PDF."""
|
|
from datetime import date, timedelta
|
|
from unittest import mock
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from django.core.exceptions import ValidationError
|
|
from django.test import Client, TestCase
|
|
from django.urls import reverse
|
|
|
|
from core.models import Directory, DirectoryType
|
|
from apps.hiring.models import HiringRequest, InternAccount
|
|
from apps.hiring.services import approve_hiring_request, create_hiring_request, reject_hiring_request
|
|
from apps.it_manager.models import Ticket
|
|
from apps.locations.models import Location, Pharmacy
|
|
|
|
Employee = get_user_model()
|
|
|
|
_employee_seq = 0
|
|
|
|
|
|
from core.test_utils import login_2fa
|
|
|
|
|
|
def make_employee(**overrides):
|
|
global _employee_seq
|
|
_employee_seq += 1
|
|
defaults = {
|
|
'username': f'hiring.hr{_employee_seq}',
|
|
'email': f'hiring.hr{_employee_seq}@company.local',
|
|
'first_name': 'Ганна',
|
|
'last_name': 'HR-Тестова',
|
|
'hire_date': date(2026, 1, 1),
|
|
'user_location_type': 'office',
|
|
'is_staff': False,
|
|
'mattermost_sync_enabled': False,
|
|
'ad_sync_enabled': False,
|
|
}
|
|
defaults.update(overrides)
|
|
return Employee.objects.create(**defaults)
|
|
|
|
|
|
def make_intern(label='Intern-1', username=None, password='intern-pass'):
|
|
return InternAccount.objects.create(
|
|
label=label,
|
|
username=username or f'intern.{label.lower().replace("-", "")}',
|
|
password=password,
|
|
is_active=True,
|
|
)
|
|
|
|
|
|
class HiringServiceTests(TestCase):
|
|
|
|
@classmethod
|
|
def setUpTestData(cls):
|
|
org_type = DirectoryType.objects.create(code='organization', name='Организация')
|
|
region_type = DirectoryType.objects.create(code='region', name='Регион')
|
|
dept_type = DirectoryType.objects.create(code='department', name='Відділ')
|
|
DirectoryType.objects.get_or_create(code='access_system', defaults={'name': 'Системи доступу'})
|
|
|
|
cls.region = Directory.objects.create(directory_type=region_type, code='od', name='Одеса')
|
|
cls.org = Directory.objects.create(directory_type=org_type, code='akc', name='АКС')
|
|
cls.department = Directory.objects.create(directory_type=dept_type, code='hr', name='HR')
|
|
cls.pharmacy = Pharmacy.objects.create(
|
|
organization=cls.org, location_region=cls.region,
|
|
pharmacy_number=1, location_type='pharmacy',
|
|
)
|
|
cls.office = Location.objects.create(
|
|
location_type='office', location_region=cls.region,
|
|
street='Дерибасівська', house_number='1',
|
|
)
|
|
|
|
def setUp(self):
|
|
from django.contrib.auth.models import Group
|
|
self.hr_user = make_employee()
|
|
self.hr_user.groups.add(Group.objects.get(name='HR'))
|
|
# Сид-миграция 0005 наполняет пул Intern-1..5 — очищаем, чтобы тесты сами
|
|
# управляли составом стажёров (иначе конфликт лейблов и «свободные» из сида).
|
|
InternAccount.objects.all().delete()
|
|
self.intern = make_intern('Intern-1')
|
|
|
|
def _pharmacy_kwargs(self, **extra):
|
|
defaults = dict(
|
|
requester=self.hr_user,
|
|
last_name='Кандидат',
|
|
first_name='Іван',
|
|
user_location_type='pharmacy',
|
|
user_pharmacy_id=self.pharmacy.pk,
|
|
hire_date=date.today() + timedelta(days=1),
|
|
trial_days=3,
|
|
)
|
|
defaults.update(extra)
|
|
return defaults
|
|
|
|
def _office_kwargs(self, **extra):
|
|
defaults = dict(
|
|
requester=self.hr_user,
|
|
last_name='Офісний',
|
|
first_name='Кандидат',
|
|
user_location_type='office',
|
|
department_id=self.department.pk,
|
|
user_location_id=self.office.pk,
|
|
hire_date=date.today() + timedelta(days=1),
|
|
trial_days=2,
|
|
)
|
|
defaults.update(extra)
|
|
return defaults
|
|
|
|
def test_create_assigns_intern_and_ticket_without_employee(self):
|
|
hire_date = date.today() + timedelta(days=1)
|
|
hr = create_hiring_request(**self._pharmacy_kwargs(hire_date=hire_date))
|
|
|
|
self.assertEqual(hr.status, 'trial')
|
|
self.assertIsNone(hr.employee)
|
|
self.assertIsNotNone(hr.granted_ticket)
|
|
self.assertEqual(hr.granted_ticket.ticket_type.code, 'access')
|
|
self.assertEqual(hr.granted_ticket.priority.code, 'high')
|
|
self.assertEqual(hr.intern_account.pk, self.intern.pk)
|
|
self.assertEqual(hr.trial_end_date, hire_date + timedelta(days=3))
|
|
self.assertFalse(hr.is_overdue)
|
|
self.assertIsNone(hr.access_task)
|
|
|
|
def test_no_free_intern_raises(self):
|
|
create_hiring_request(**self._pharmacy_kwargs())
|
|
with self.assertRaises(ValidationError) as cm:
|
|
create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='Другий', first_name='Кандидат',
|
|
))
|
|
self.assertIn('__all__', cm.exception.message_dict)
|
|
|
|
def test_pharmacy_without_pharmacy_raises(self):
|
|
with self.assertRaises(ValidationError) as cm:
|
|
create_hiring_request(**self._pharmacy_kwargs(user_pharmacy_id=None))
|
|
self.assertIn('user_pharmacy', cm.exception.message_dict)
|
|
|
|
def test_office_without_department_raises(self):
|
|
with self.assertRaises(ValidationError) as cm:
|
|
create_hiring_request(**self._office_kwargs(department_id=None))
|
|
self.assertIn('department', cm.exception.message_dict)
|
|
|
|
def test_office_without_location_raises(self):
|
|
with self.assertRaises(ValidationError) as cm:
|
|
create_hiring_request(**self._office_kwargs(user_location_id=None))
|
|
self.assertIn('user_location', cm.exception.message_dict)
|
|
|
|
def test_approve_assigns_default_groups_by_location(self):
|
|
from django.contrib.auth.models import Group
|
|
from apps.employees.models import EmployeeDefaults
|
|
role = Group.objects.create(name='Аптека-базовая')
|
|
EmployeeDefaults.load().default_groups_pharmacy.add(role)
|
|
|
|
hr = create_hiring_request(**self._pharmacy_kwargs())
|
|
approved = approve_hiring_request(hr, decided_by=self.hr_user)
|
|
self.assertIn(role, approved.employee.groups.all())
|
|
|
|
def test_approve_does_not_assign_other_location_groups(self):
|
|
from django.contrib.auth.models import Group
|
|
from apps.employees.models import EmployeeDefaults
|
|
pharm_role = Group.objects.create(name='Аптека-роль')
|
|
EmployeeDefaults.load().default_groups_pharmacy.add(pharm_role)
|
|
|
|
hr = create_hiring_request(**self._office_kwargs())
|
|
approved = approve_hiring_request(hr, decided_by=self.hr_user)
|
|
self.assertNotIn(pharm_role, approved.employee.groups.all())
|
|
|
|
def test_is_overdue_when_trial_period_passed(self):
|
|
hr = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='Прострочений', first_name='Тест',
|
|
hire_date=date(2026, 1, 1), trial_days=1,
|
|
))
|
|
self.assertTrue(hr.is_overdue)
|
|
|
|
def _access_ids(self, n=2):
|
|
ids = list(Directory.objects.filter(directory_type__code='access_system', is_active=True)
|
|
.values_list('id', flat=True)[:n])
|
|
self.assertTrue(ids, 'у тестовій БД немає довідника access_system')
|
|
return ids
|
|
|
|
def test_create_with_access_creates_ticket_not_task(self):
|
|
access_ids = self._access_ids(2)
|
|
hr = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='Доступний', first_name='Кандидат',
|
|
requested_access_ids=access_ids,
|
|
))
|
|
|
|
self.assertIsNone(hr.access_task)
|
|
self.assertIsNotNone(hr.granted_ticket)
|
|
self.assertEqual(hr.requested_access.count(), len(access_ids))
|
|
self.assertIn('Доступний', hr.granted_ticket.description)
|
|
self.assertEqual(Ticket.objects.filter(pk=hr.granted_ticket_id).count(), 1)
|
|
|
|
def test_create_without_access_still_creates_ticket(self):
|
|
hr = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='Без', first_name='Доступів',
|
|
))
|
|
self.assertIsNone(hr.access_task)
|
|
self.assertIsNotNone(hr.granted_ticket)
|
|
|
|
def test_access_for_office_only_skipped_for_pharmacy(self):
|
|
access = Directory.objects.filter(
|
|
directory_type__code='access_system', is_active=True,
|
|
).first()
|
|
self.assertIsNotNone(access)
|
|
loc_type = DirectoryType.objects.get(code='location_type')
|
|
office_lt = Directory.objects.get_or_create(
|
|
directory_type=loc_type, code='office',
|
|
defaults={'name': 'Офіс', 'is_active': True},
|
|
)[0]
|
|
access.location_types.set([office_lt])
|
|
|
|
hr = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='Фільтр', first_name='Доступ',
|
|
requested_access_ids=[access.pk],
|
|
))
|
|
self.assertEqual(hr.requested_access.count(), 0)
|
|
|
|
make_intern('Intern-2')
|
|
hr_office = create_hiring_request(**self._office_kwargs(
|
|
last_name='Фільтр', first_name='Офіс',
|
|
requested_access_ids=[access.pk],
|
|
))
|
|
self.assertEqual(list(hr_office.requested_access.values_list('pk', flat=True)), [access.pk])
|
|
|
|
def test_approve_creates_employee_and_frees_intern(self):
|
|
hr = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='Кандидат', first_name='Марія',
|
|
hire_date=date(2026, 6, 1), trial_days=5,
|
|
))
|
|
ticket = hr.granted_ticket
|
|
approved = approve_hiring_request(hr, decided_by=self.hr_user, note='ok')
|
|
|
|
self.assertEqual(approved.status, 'approved')
|
|
self.assertEqual(approved.decision_by, self.hr_user)
|
|
self.assertIsNotNone(approved.employee)
|
|
self.assertEqual(approved.issued_password, '')
|
|
self.assertFalse(approved.employee.has_usable_password())
|
|
self.assertEqual(approved.employee.probation_end_date, date.today())
|
|
|
|
self.intern.refresh_from_db()
|
|
self.assertIsNone(self.intern.assigned_to_id)
|
|
ticket.refresh_from_db()
|
|
self.assertEqual(ticket.status.code, 'closed')
|
|
|
|
def test_approve_office_assigns_corporate_email(self):
|
|
from apps.employees.models import EmployeeDefaults
|
|
EmployeeDefaults.load()
|
|
|
|
hr = create_hiring_request(**self._office_kwargs(
|
|
last_name='Петренко', first_name='Іван',
|
|
))
|
|
approve_hiring_request(hr, decided_by=self.hr_user)
|
|
hr.refresh_from_db()
|
|
self.assertEqual(
|
|
hr.employee.email,
|
|
f'{hr.employee.username}@aks-apteka.com.ua',
|
|
)
|
|
|
|
def test_approve_office_uses_admin_domain_setting(self):
|
|
from apps.employees.models import EmployeeDefaults
|
|
defaults = EmployeeDefaults.load()
|
|
defaults.office_email_domain = 'office.example.com'
|
|
defaults.save()
|
|
|
|
hr = create_hiring_request(**self._office_kwargs(
|
|
last_name='Сидоренко', first_name='Оля',
|
|
))
|
|
approve_hiring_request(hr, decided_by=self.hr_user)
|
|
hr.refresh_from_db()
|
|
self.assertEqual(
|
|
hr.employee.email,
|
|
f'{hr.employee.username}@office.example.com',
|
|
)
|
|
|
|
def test_approve_pharmacy_uses_placeholder_email(self):
|
|
hr = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='Аптечна', first_name='Марія',
|
|
))
|
|
approve_hiring_request(hr, decided_by=self.hr_user)
|
|
hr.refresh_from_db()
|
|
self.assertTrue(hr.employee.email.endswith('@local.domain'))
|
|
|
|
def test_approve_does_not_enable_ad_sync(self):
|
|
"""HR approve не включает AD sync — учётку в домене создаёт только IT."""
|
|
office_hr = create_hiring_request(**self._office_kwargs())
|
|
make_intern('Intern-2')
|
|
pharmacy_hr = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='Аптечний', first_name='Кандидат',
|
|
))
|
|
approve_hiring_request(office_hr, decided_by=self.hr_user)
|
|
approve_hiring_request(pharmacy_hr, decided_by=self.hr_user)
|
|
office_hr.refresh_from_db()
|
|
pharmacy_hr.refresh_from_db()
|
|
self.assertFalse(office_hr.employee.ad_sync_enabled)
|
|
self.assertFalse(pharmacy_hr.employee.ad_sync_enabled)
|
|
|
|
def test_approve_office_does_not_queue_ad_sync(self):
|
|
hr = create_hiring_request(**self._office_kwargs())
|
|
with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay:
|
|
approve_hiring_request(hr, decided_by=self.hr_user)
|
|
delay.assert_not_called()
|
|
|
|
def test_approve_generates_unique_username(self):
|
|
hr1 = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='Дубль', first_name='Петро', hire_date=date(2026, 6, 1), trial_days=1,
|
|
))
|
|
make_intern('Intern-2')
|
|
hr2 = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='Дубль', first_name='Петро', hire_date=date(2026, 6, 1), trial_days=1,
|
|
))
|
|
approve_hiring_request(hr1, decided_by=self.hr_user)
|
|
approve_hiring_request(hr2, decided_by=self.hr_user)
|
|
hr1.refresh_from_db()
|
|
hr2.refresh_from_db()
|
|
self.assertNotEqual(hr1.employee.username, hr2.employee.username)
|
|
|
|
def test_reject_frees_intern_closes_ticket_no_employee(self):
|
|
hr = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='Кандидат', first_name='Олег',
|
|
hire_date=date(2026, 6, 1), trial_days=2,
|
|
))
|
|
ticket = hr.granted_ticket
|
|
rejected = reject_hiring_request(hr, decided_by=self.hr_user, note='не пройшов')
|
|
|
|
self.assertEqual(rejected.status, 'rejected')
|
|
self.assertIsNone(rejected.employee)
|
|
self.intern.refresh_from_db()
|
|
self.assertIsNone(self.intern.assigned_to_id)
|
|
ticket.refresh_from_db()
|
|
self.assertEqual(ticket.status.code, 'closed')
|
|
|
|
def test_decision_is_a_noop_once_already_decided(self):
|
|
hr = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='Кандидат', first_name='Ліда',
|
|
hire_date=date(2026, 6, 1), trial_days=2,
|
|
))
|
|
approve_hiring_request(hr, decided_by=self.hr_user)
|
|
reject_hiring_request(hr, decided_by=self.hr_user)
|
|
hr.refresh_from_db()
|
|
self.assertEqual(hr.status, 'approved')
|
|
|
|
def test_credentials_pdf_after_approve(self):
|
|
hr = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='PDF', first_name='Тест',
|
|
))
|
|
approve_hiring_request(hr, decided_by=self.hr_user)
|
|
|
|
client = Client()
|
|
login_2fa(client, self.hr_user)
|
|
url = reverse('hiring_credentials_pdf', kwargs={'pk': hr.pk})
|
|
response = client.get(url)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response['Content-Type'], 'application/pdf')
|
|
self.assertTrue(response.content.startswith(b'%PDF'))
|
|
|
|
hr.refresh_from_db()
|
|
self.assertEqual(hr.issued_password, '')
|
|
second = client.get(url)
|
|
self.assertEqual(second.status_code, 200)
|
|
|
|
def test_credentials_pdf_forbidden_while_trial(self):
|
|
hr = create_hiring_request(**self._pharmacy_kwargs(
|
|
last_name='PDF', first_name='Trial',
|
|
))
|
|
client = Client()
|
|
login_2fa(client, self.hr_user)
|
|
response = client.get(reverse('hiring_credentials_pdf', kwargs={'pk': hr.pk}))
|
|
self.assertEqual(response.status_code, 403)
|
|
|
|
def test_hr_portal_shows_hiring_ui_without_staff(self):
|
|
client = Client()
|
|
login_2fa(client, self.hr_user)
|
|
response = client.get('/hr-portal/', HTTP_HX_REQUEST='true')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertTrue(response.context['can_manage_hiring'])
|
|
self.assertContains(response, 'Новая заявка на найм')
|