diff --git a/apps/hiring/admin.py b/apps/hiring/admin.py index 84ffcd3..596123c 100644 --- a/apps/hiring/admin.py +++ b/apps/hiring/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from unfold.admin import ModelAdmin -from apps.hiring.models import HiringRequest, InternAccount +from apps.hiring.models import HiringRequest, InternAccount, Vacancy @admin.register(InternAccount) @@ -14,6 +14,19 @@ class InternAccountAdmin(ModelAdmin): readonly_fields = ['assigned_to', 'created_at', 'updated_at', 'uuid'] +@admin.register(Vacancy) +class VacancyAdmin(ModelAdmin): + list_display = [ + 'title', 'status', 'priority', 'pharmacy', 'department', + 'candidates_count', 'opened_at', 'created_by', + ] + list_filter = ['status', 'priority', 'user_location_type'] + search_fields = ['title', 'description'] + list_select_related = ['post', 'pharmacy', 'department', 'location', 'created_by'] + ordering = ['-opened_at', '-created_at'] + readonly_fields = ['created_at', 'updated_at', 'uuid'] + + @admin.register(HiringRequest) class HiringRequestAdmin(ModelAdmin): list_display = [ diff --git a/apps/hiring/migrations/0008_vacancy.py b/apps/hiring/migrations/0008_vacancy.py new file mode 100644 index 0000000..4db6483 --- /dev/null +++ b/apps/hiring/migrations/0008_vacancy.py @@ -0,0 +1,47 @@ +# Generated by Django 6.0.3 on 2026-09-08 19:23 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0017_rename_databases_section'), + ('hiring', '0007_rotate_seed_intern_passwords'), + ('locations', '0013_pharmacy_edit_permission'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Vacancy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=200, verbose_name='Название')), + ('user_location_type', models.CharField(choices=[('pharmacy', 'Аптека'), ('warehouse', 'Склад'), ('office', 'Офис')], default='pharmacy', max_length=20, verbose_name='Тип локации')), + ('priority', models.CharField(choices=[('urgent', 'Срочно'), ('medium', 'Средний'), ('normal', 'Обычный')], default='normal', max_length=16, verbose_name='Приоритет')), + ('status', models.CharField(choices=[('open', 'Открыта'), ('closed', 'Закрыта'), ('filled', 'Закрыта наймом')], default='open', max_length=16, verbose_name='Статус')), + ('opened_at', models.DateField(default=django.utils.timezone.localdate, verbose_name='Открыта с')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('candidates_count', models.PositiveIntegerField(default=0, verbose_name='Кандидатов')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vacancies_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ('department', models.ForeignKey(blank=True, limit_choices_to={'directory_type__code': 'department', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vacancies_department', to='core.directory', verbose_name='Отдел')), + ('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='location_vacancies', to='locations.location', verbose_name='Локация')), + ('pharmacy', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pharmacy_vacancies', to='locations.pharmacy', verbose_name='Аптека')), + ('post', models.ForeignKey(blank=True, limit_choices_to={'directory_type__code': 'post', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vacancies', to='core.directory', verbose_name='Должность')), + ], + options={ + 'verbose_name': 'Вакансия', + 'verbose_name_plural': 'Вакансии', + 'ordering': ['-opened_at', '-created_at'], + 'indexes': [models.Index(fields=['status', 'priority'], name='hiring_vaca_status_3522a0_idx')], + }, + ), + ] diff --git a/apps/hiring/models.py b/apps/hiring/models.py index b353281..2e31798 100644 --- a/apps/hiring/models.py +++ b/apps/hiring/models.py @@ -186,3 +186,85 @@ class HiringRequest(BaseModel): except Exception: return account.release() + + +class Vacancy(BaseModel): + """Открытая вакансия (рекрутинг в HR-портале).""" + + STATUS_CHOICES = [ + ('open', _('Открыта')), + ('closed', _('Закрыта')), + ('filled', _('Закрыта наймом')), + ] + PRIORITY_CHOICES = [ + ('urgent', _('Срочно')), + ('medium', _('Средний')), + ('normal', _('Обычный')), + ] + LOCATION_TYPES = [ + ('pharmacy', _('Аптека')), + ('warehouse', _('Склад')), + ('office', _('Офис')), + ] + + title = models.CharField(_('Название'), max_length=200) + post = models.ForeignKey( + 'core.Directory', on_delete=models.SET_NULL, + null=True, blank=True, + limit_choices_to={'directory_type__code': 'post', 'is_active': True}, + related_name='vacancies', verbose_name=_('Должность'), + ) + user_location_type = models.CharField( + _('Тип локации'), max_length=20, choices=LOCATION_TYPES, default='pharmacy', + ) + department = models.ForeignKey( + 'core.Directory', on_delete=models.SET_NULL, + null=True, blank=True, + limit_choices_to={'directory_type__code': 'department', 'is_active': True}, + related_name='vacancies_department', verbose_name=_('Отдел'), + ) + pharmacy = models.ForeignKey( + 'locations.Pharmacy', on_delete=models.SET_NULL, + null=True, blank=True, + related_name='pharmacy_vacancies', verbose_name=_('Аптека'), + ) + location = models.ForeignKey( + 'locations.Location', on_delete=models.SET_NULL, + null=True, blank=True, + related_name='location_vacancies', verbose_name=_('Локация'), + ) + priority = models.CharField( + _('Приоритет'), max_length=16, choices=PRIORITY_CHOICES, default='normal', + ) + status = models.CharField( + _('Статус'), max_length=16, choices=STATUS_CHOICES, default='open', + ) + opened_at = models.DateField(_('Открыта с'), default=timezone.localdate) + description = models.TextField(_('Описание'), blank=True) + candidates_count = models.PositiveIntegerField(_('Кандидатов'), default=0) + created_by = models.ForeignKey( + 'employees.Employee', on_delete=models.SET_NULL, + null=True, blank=True, + related_name='vacancies_created', verbose_name=_('Создал'), + ) + + class Meta: + verbose_name = _('Вакансия') + verbose_name_plural = _('Вакансии') + ordering = ['-opened_at', '-created_at'] + indexes = [ + models.Index(fields=['status', 'priority']), + ] + + def __str__(self): + return self.title + + @property + def place_label(self) -> str: + if self.user_location_type == 'pharmacy' and self.pharmacy_id: + return str(self.pharmacy) + if self.department_id: + return self.department.name + if self.location_id: + return str(self.location) + return '—' diff --git a/apps/hiring/pdf.py b/apps/hiring/pdf.py index 59962ed..c1cd28d 100644 --- a/apps/hiring/pdf.py +++ b/apps/hiring/pdf.py @@ -18,7 +18,7 @@ def _resolve_font(): return None -def build_credentials_pdf(hiring_request) -> bytes: +def build_credentials_pdf(hiring_request, *, invite_url: str = '') -> bytes: from fpdf import FPDF from fpdf.enums import XPos, YPos @@ -50,7 +50,7 @@ def build_credentials_pdf(hiring_request) -> bytes: f"{_('Должность')}: {employee.post.name if employee.post_id else dash}", f"{_('Тип локации')}: {hiring_request.get_user_location_type_display()}", f"{_('Логин')}: {employee.username}", - f"{_('Пароль')}: {hiring_request.issued_password or dash}", + f"{_('Посилання для пароля')}: {invite_url or dash}", f"{_('Рабочий email')}: {employee.email or dash}", '', f"{_('Запрошенные доступы')}:", diff --git a/apps/hiring/recruiting.py b/apps/hiring/recruiting.py new file mode 100644 index 0000000..2909fe0 --- /dev/null +++ b/apps/hiring/recruiting.py @@ -0,0 +1,269 @@ +"""Рекрутинг: вакансии в HR-портале.""" +from __future__ import annotations + +from django.contrib.auth.decorators import login_required +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.shortcuts import get_object_or_404, redirect +from django.utils import timezone +from django.views.decorators.http import require_POST + +from apps.hiring.models import Vacancy +from core.portal_access import user_can_manage_hr_portal + + +RECRUITING_PERM = 'core.access_recruiting' +MANAGER_GROUP_NAME = 'Заведующий аптекой' +PHARMACIST_GROUP_NAME = 'Фармацевт' + + +def _preview_groups(user) -> frozenset[str] | None: + return getattr(user, '_access_preview_groups', None) + + +def user_can_view_recruiting(user) -> bool: + if not user or not getattr(user, 'is_authenticated', False): + return False + preview = getattr(user, '_access_preview_perms', None) + if preview is not None: + return RECRUITING_PERM in preview or 'core.access_hr_portal' in preview + if getattr(user, 'is_superuser', False): + return True + if user_can_manage_hr_portal(user): + return True + return user.has_perm(RECRUITING_PERM) + + +def user_is_pharmacy_manager(user) -> bool: + if not user or not getattr(user, 'is_authenticated', False): + return False + groups = _preview_groups(user) + if groups is not None: + return MANAGER_GROUP_NAME in groups + return user.groups.filter(name=MANAGER_GROUP_NAME).exists() + + +def user_is_pharmacist(user) -> bool: + if not user or not getattr(user, 'is_authenticated', False): + return False + if user_can_manage_hr_portal(user): + return False + groups = _preview_groups(user) + if groups is not None: + return PHARMACIST_GROUP_NAME in groups and MANAGER_GROUP_NAME not in groups + return user.groups.filter(name=PHARMACIST_GROUP_NAME).exists() + + +def user_can_manage_vacancies(user) -> bool: + """HR или заведующий с правом рекрутинга — создание/редактирование вакансий. + + Фармацевт только смотрит вакансии. В access-preview superuser не обходит роль. + """ + if not user or not getattr(user, 'is_authenticated', False): + return False + preview = getattr(user, '_access_preview_perms', None) + if preview is not None: + if user_is_pharmacist(user): + return False + if 'core.access_hr_portal' in preview: + return True + return user_is_pharmacy_manager(user) and RECRUITING_PERM in preview + if getattr(user, 'is_superuser', False): + return True + if user_can_manage_hr_portal(user): + return True + if user_is_pharmacist(user): + return False + return user_is_pharmacy_manager(user) and user.has_perm(RECRUITING_PERM) + + +def vacancies_queryset_for(user): + qs = Vacancy.objects.select_related( + 'post', 'department', 'pharmacy', 'location', 'created_by', + ) + preview = getattr(user, '_access_preview_perms', None) + if preview is None and ( + user_can_manage_hr_portal(user) or getattr(user, 'is_superuser', False) + ): + return qs + if preview is not None and 'core.access_hr_portal' in preview: + return qs + # Фармацевты / заведующие — открытые + свои аптечные + pharmacy_id = getattr(user, 'user_pharmacy_id', None) + if pharmacy_id: + return qs.filter(Q(status='open') | Q(pharmacy_id=pharmacy_id)) + return qs.filter(status='open') + + +def vacancy_stats_for(user) -> dict: + qs = vacancies_queryset_for(user) + today = timezone.localdate() + month_start = today.replace(day=1) + open_qs = qs.filter(status='open') + return { + 'vacancies_open': open_qs.count(), + 'vacancies_urgent': open_qs.filter(priority='urgent').count(), + 'vacancies_candidates': sum(open_qs.values_list('candidates_count', flat=True)), + 'vacancies_filled_month': qs.filter( + status='filled', updated_at__date__gte=month_start, + ).count(), + } + + +def recruiting_context(request) -> dict: + from apps.locations.models.pharmacies import Pharmacy + from apps.locations.models import Location + from core.models import Directory + + can_view = user_can_view_recruiting(request.user) + can_manage = user_can_manage_vacancies(request.user) + vacancies = ( + list(vacancies_queryset_for(request.user).order_by('-opened_at', '-pk')[:200]) + if can_view else [] + ) + stats = vacancy_stats_for(request.user) if can_view else { + 'vacancies_open': 0, + 'vacancies_urgent': 0, + 'vacancies_candidates': 0, + 'vacancies_filled_month': 0, + } + ctx = { + 'can_view_recruiting': can_view, + 'can_manage_vacancies': can_manage, + 'vacancies': vacancies, + **stats, + 'vacancy_message': request.session.pop('vacancy_message', ''), + 'vacancy_error': request.session.pop('vacancy_error', ''), + 'vacancy_posts': [], + 'vacancy_departments': [], + 'vacancy_pharmacies': [], + 'vacancy_locations': [], + } + if can_manage: + ctx['vacancy_posts'] = list( + Directory.objects.filter(directory_type__code='post', is_active=True).order_by('name') + ) + ctx['vacancy_departments'] = list( + Directory.objects.filter(directory_type__code='department', is_active=True).order_by('name') + ) + if user_can_manage_hr_portal(request.user) or request.user.is_superuser: + ctx['vacancy_pharmacies'] = list( + Pharmacy.objects.filter(is_active=True).order_by('pharmacy_number')[:500] + ) + ctx['vacancy_locations'] = list( + Location.objects.filter(is_active=True).order_by('street', 'house_number')[:500] + ) + else: + ph = getattr(request.user, 'user_pharmacy', None) + ctx['vacancy_pharmacies'] = [ph] if ph else [] + return ctx + + +def save_vacancy_from_post(request, vacancy: Vacancy | None = None) -> Vacancy: + user = request.user + is_hr = user_can_manage_hr_portal(user) or user.is_superuser + title = (request.POST.get('title') or '').strip() + if not title: + raise ValidationError({'title': 'Укажите название вакансии.'}) + + post_id = (request.POST.get('post_id') or '').strip() + loc_type = (request.POST.get('user_location_type') or 'pharmacy').strip() + if loc_type not in dict(Vacancy.LOCATION_TYPES): + loc_type = 'pharmacy' + priority = (request.POST.get('priority') or 'normal').strip() + if priority not in dict(Vacancy.PRIORITY_CHOICES): + priority = 'normal' + description = (request.POST.get('description') or '').strip() + candidates_raw = (request.POST.get('candidates_count') or '0').strip() + try: + candidates_count = max(0, int(candidates_raw)) + except ValueError: + candidates_count = 0 + + pharmacy_id = (request.POST.get('pharmacy_id') or '').strip() + department_id = (request.POST.get('department_id') or '').strip() + location_id = (request.POST.get('location_id') or '').strip() + + if not is_hr: + # Заведующий — только своя аптека + loc_type = 'pharmacy' + pharmacy_id = str(getattr(user, 'user_pharmacy_id', '') or '') + if not pharmacy_id: + raise ValidationError({'pharmacy_id': 'У вашей учётной записи не указана аптека.'}) + department_id = '' + location_id = '' + + obj = vacancy or Vacancy(created_by=user, opened_at=timezone.localdate()) + obj.title = title + obj.post_id = int(post_id) if post_id.isdigit() else None + obj.user_location_type = loc_type + obj.priority = priority + obj.description = description + obj.candidates_count = candidates_count + obj.pharmacy_id = int(pharmacy_id) if pharmacy_id.isdigit() else None + obj.department_id = int(department_id) if department_id.isdigit() else None + obj.location_id = int(location_id) if location_id.isdigit() else None + if loc_type == 'pharmacy': + obj.department = None + obj.location = None + else: + obj.pharmacy = None + if vacancy is None: + obj.created_by = user + obj.full_clean() + obj.save() + return obj + + +@login_required +@require_POST +def vacancy_save_view(request): + if not user_can_manage_vacancies(request.user): + request.session['vacancy_error'] = 'Нет прав на изменение вакансий.' + return redirect('/hr-portal/?tab=vacancies') + + pk = (request.POST.get('pk') or '').strip() + vacancy = None + if pk.isdigit(): + vacancy = get_object_or_404(Vacancy, pk=int(pk)) + if not (user_can_manage_hr_portal(request.user) or request.user.is_superuser): + if vacancy.pharmacy_id != getattr(request.user, 'user_pharmacy_id', None): + request.session['vacancy_error'] = 'Можно редактировать только вакансии своей аптеки.' + return redirect('/hr-portal/?tab=vacancies') + + try: + save_vacancy_from_post(request, vacancy) + request.session['vacancy_message'] = 'Вакансия сохранена.' + except ValidationError as exc: + msgs = [] + if hasattr(exc, 'message_dict'): + for errs in exc.message_dict.values(): + msgs.extend(errs) + elif hasattr(exc, 'messages'): + msgs.extend(exc.messages) + request.session['vacancy_error'] = '; '.join(str(m) for m in msgs) or str(exc) + + return redirect('/hr-portal/?tab=vacancies') + + +@login_required +@require_POST +def vacancy_set_status_view(request, pk: int): + if not user_can_manage_vacancies(request.user): + request.session['vacancy_error'] = 'Нет прав на изменение вакансий.' + return redirect('/hr-portal/?tab=vacancies') + + vacancy = get_object_or_404(Vacancy, pk=pk) + if not (user_can_manage_hr_portal(request.user) or request.user.is_superuser): + if vacancy.pharmacy_id != getattr(request.user, 'user_pharmacy_id', None): + request.session['vacancy_error'] = 'Можно менять только вакансии своей аптеки.' + return redirect('/hr-portal/?tab=vacancies') + + status = (request.POST.get('status') or '').strip() + if status not in dict(Vacancy.STATUS_CHOICES): + request.session['vacancy_error'] = 'Некорректный статус.' + return redirect('/hr-portal/?tab=vacancies') + vacancy.status = status + vacancy.save(update_fields=['status', 'updated_at']) + request.session['vacancy_message'] = 'Статус вакансии обновлён.' + return redirect('/hr-portal/?tab=vacancies') diff --git a/apps/hiring/services.py b/apps/hiring/services.py index 8c738dd..57469c9 100644 --- a/apps/hiring/services.py +++ b/apps/hiring/services.py @@ -216,7 +216,6 @@ def approve_hiring_request(hiring_request, decided_by, note=''): return locked username = _generate_username(locked.last_name, locked.first_name) - password = _generate_password() employee = Employee( username=username, @@ -233,16 +232,19 @@ def approve_hiring_request(hiring_request, decided_by, note=''): user_pharmacy_id=locked.user_pharmacy_id, user_location_id=locked.user_location_id, post_id=locked.post_id, - ad_sync_enabled=(locked.user_location_type == 'office'), + ad_sync_enabled=False, ) if _should_assign_office_email(locked.user_location_type): employee.email = _office_email_for(username) - employee.set_password(password) + employee.set_unusable_password() employee.full_clean() employee.save() + from apps.employees.services.password_invite import create_password_invite + invite = create_password_invite(employee, created_by=decided_by) + locked.employee = employee - locked.issued_password = password + locked.issued_password = '' locked.status = 'approved' locked.decision_by = decided_by locked.decision_at = timezone.now() @@ -253,6 +255,16 @@ def approve_hiring_request(hiring_request, decided_by, note=''): ]) locked.release_intern() _close_ticket(locked.granted_ticket) + + from core.security import log_security_event + + log_security_event( + 'employee.created_via_hiring', + actor=decided_by, + target=str(employee.pk), + details=f'username={employee.username} location={employee.user_location_type} invite={invite.pk}', + ) + locked._password_invite = invite # для view/шаблону (не зберігається в БД) return locked @@ -276,23 +288,33 @@ def reject_hiring_request(hiring_request, decided_by, note=''): @transaction.atomic -def consume_issued_password_for_pdf(hiring_request): - """Повертає PDF-байти й одразу очищає issued_password (one-time download).""" +def build_hiring_credentials_pdf(hiring_request, request=None): + """PDF з логіном і посиланням на встановлення пароля.""" from apps.hiring.pdf import build_credentials_pdf + from apps.employees.services.password_invite import ( + build_invite_absolute_url, + create_password_invite, + get_active_invite_for_employee, + ) locked = HiringRequest.objects.select_for_update().get(pk=hiring_request.pk) - if ( - locked.status != 'approved' - or locked.employee_id is None - or not locked.issued_password - ): + if locked.status != 'approved' or locked.employee_id is None: return None - # Related для PDF — окремим запитом без FOR UPDATE (nullable FK + Postgres). + + invite = get_active_invite_for_employee(locked.employee) + if invite is None and not locked.employee.has_usable_password(): + invite = create_password_invite(locked.employee, created_by=None) + + invite_url = build_invite_absolute_url(request, invite) if invite else '' locked = ( HiringRequest.objects.select_related('employee', 'post') .prefetch_related('requested_access') .get(pk=locked.pk) ) - content = build_credentials_pdf(locked) - HiringRequest.objects.filter(pk=locked.pk).update(issued_password='') - return content + return build_credentials_pdf(locked, invite_url=invite_url) + + +@transaction.atomic +def consume_issued_password_for_pdf(hiring_request): + """Зворотна сумісність: PDF без одноразового пароля.""" + return build_hiring_credentials_pdf(hiring_request) diff --git a/apps/hiring/tests.py b/apps/hiring/tests.py index dedb283..731dcfd 100644 --- a/apps/hiring/tests.py +++ b/apps/hiring/tests.py @@ -1,5 +1,6 @@ """Тести найму: пул інтернів, 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 @@ -17,14 +18,7 @@ Employee = get_user_model() _employee_seq = 0 -def login_2fa(client, user): - client.force_login(user) - if user.is_staff and not user.totp_enabled: - user.totp_enabled = True - user.save(update_fields=['totp_enabled']) - session = client.session - session['2fa_verified'] = True - session.save() +from core.test_utils import login_2fa def make_employee(**overrides): @@ -217,9 +211,9 @@ class HiringServiceTests(TestCase): self.assertEqual(approved.status, 'approved') self.assertEqual(approved.decision_by, self.hr_user) self.assertIsNotNone(approved.employee) - self.assertTrue(approved.issued_password) + self.assertEqual(approved.issued_password, '') + self.assertFalse(approved.employee.has_usable_password()) self.assertEqual(approved.employee.probation_end_date, date.today()) - self.assertTrue(approved.employee.check_password(approved.issued_password)) self.intern.refresh_from_db() self.assertIsNone(self.intern.assigned_to_id) @@ -264,7 +258,8 @@ class HiringServiceTests(TestCase): hr.refresh_from_db() self.assertTrue(hr.employee.email.endswith('@local.domain')) - def test_approve_ad_sync_only_for_office(self): + 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( @@ -274,9 +269,15 @@ class HiringServiceTests(TestCase): approve_hiring_request(pharmacy_hr, decided_by=self.hr_user) office_hr.refresh_from_db() pharmacy_hr.refresh_from_db() - self.assertTrue(office_hr.employee.ad_sync_enabled) + 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, @@ -333,7 +334,7 @@ class HiringServiceTests(TestCase): hr.refresh_from_db() self.assertEqual(hr.issued_password, '') second = client.get(url) - self.assertEqual(second.status_code, 403) + self.assertEqual(second.status_code, 200) def test_credentials_pdf_forbidden_while_trial(self): hr = create_hiring_request(**self._pharmacy_kwargs( diff --git a/apps/hiring/tests_recruiting.py b/apps/hiring/tests_recruiting.py new file mode 100644 index 0000000..b46e5ea --- /dev/null +++ b/apps/hiring/tests_recruiting.py @@ -0,0 +1,105 @@ +from datetime import date + +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from apps.employees.models import Employee +from apps.hiring.models import Vacancy +from apps.hiring.recruiting import ( + user_can_manage_vacancies, + user_can_view_recruiting, +) +from core.models import PortalAccess +from core.test_utils import login_2fa + + +def make_employee(**overrides): + n = Employee.objects.count() + 1 + defaults = { + 'username': f'rec.user{n}', + 'email': f'rec.user{n}@company.local', + 'first_name': 'Тест', + 'last_name': 'Рекрутинг', + 'hire_date': date(2026, 1, 1), + 'user_location_type': 'pharmacy', + 'mattermost_sync_enabled': False, + 'ad_sync_enabled': False, + 'is_staff': False, + } + defaults.update(overrides) + return Employee.objects.create(**defaults) + + +def portal_perm(codename: str) -> Permission: + ct = ContentType.objects.get_for_model(PortalAccess, for_concrete_model=False) + return Permission.objects.get(content_type=ct, codename=codename) + + +def clear_perm_cache(user): + for attr in ('_perm_cache', '_user_perm_cache', '_group_perm_cache'): + if hasattr(user, attr): + delattr(user, attr) + + +class RecruitingAccessTests(TestCase): + def test_pharmacist_can_view_vacancies_tab(self): + user = make_employee() + group = Group.objects.get(name='Фармацевт') + group.permissions.add(portal_perm('access_recruiting')) + user.groups.add(group) + clear_perm_cache(user) + self.assertTrue(user_can_view_recruiting(user)) + self.assertFalse(user_can_manage_vacancies(user)) + + login_2fa(self.client, user) + resp = self.client.get('/hr-portal/?tab=vacancies', HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.context['can_view_recruiting']) + self.assertEqual(resp.context['hr_active_tab'], 'vacancies') + + def test_manager_can_create_vacancy(self): + from apps.locations.models.pharmacies import Pharmacy + from core.models import Directory, DirectoryType + + org_type, _ = DirectoryType.objects.get_or_create( + code='organization', defaults={'name': 'Организация'}, + ) + region_type, _ = DirectoryType.objects.get_or_create( + code='region', defaults={'name': 'Регион'}, + ) + region = Directory.objects.create(directory_type=region_type, code='rec-r', name='Рег') + org = Directory.objects.create(directory_type=org_type, code='rec-o', name='Орг') + pharmacy = Pharmacy.objects.create( + organization=org, + location_region=region, + pharmacy_number=99, + location_type='pharmacy', + street='Тест', + house_number='1', + status='open', + ) + user = make_employee(user_pharmacy=pharmacy) + group = Group.objects.get(name='Заведующий аптекой') + group.permissions.add(portal_perm('access_recruiting')) + user.groups.add(group) + clear_perm_cache(user) + self.assertTrue(user_can_manage_vacancies(user)) + + login_2fa(self.client, user) + resp = self.client.post('/vacancies/save/', { + 'title': 'Фармацевт', + 'priority': 'urgent', + 'candidates_count': '2', + 'user_location_type': 'pharmacy', + 'pharmacy_id': str(pharmacy.pk), + }) + self.assertEqual(resp.status_code, 302) + self.assertTrue(Vacancy.objects.filter(title='Фармацевт', pharmacy=pharmacy).exists()) + + def test_recruiting_redirects_to_hr(self): + user = make_employee(user_location_type='office') + login_2fa(self.client, user) + resp = self.client.get('/recruiting/') + self.assertEqual(resp.status_code, 302) + self.assertIn('/hr-portal/', resp['Location']) diff --git a/static/assets/js/hr-portal.js b/static/assets/js/hr-portal.js index 4dea643..14621fb 100644 --- a/static/assets/js/hr-portal.js +++ b/static/assets/js/hr-portal.js @@ -1,17 +1,23 @@ +function hrPortalRoot() { + return document.querySelector('#main-content .page-content[data-hr-active-tab]'); +} + function hrTabSwitch(tab) { - document.querySelectorAll('.hr-panel').forEach(function (panel) { - panel.style.display = panel.id === 'hr-panel-' + tab ? '' : 'none'; + var root = hrPortalRoot(); + if (!root) return; + root.querySelectorAll('.hr-panel').forEach(function (panel) { + panel.style.display = panel.id === 'hr-panel-' + tab ? 'block' : 'none'; }); - document.querySelectorAll('.hr-tab-btn').forEach(function (btn) { + root.querySelectorAll('.hr-tab-btn').forEach(function (btn) { var active = btn.getAttribute('data-tab') === tab; btn.classList.toggle('hr-tab-active', active); btn.style.background = active ? 'var(--color-card)' : 'transparent'; btn.style.color = active ? 'var(--color-card-foreground)' : 'var(--color-muted-foreground)'; btn.style.boxShadow = active ? '0 1px 2px rgb(0 0 0/0.08)' : ''; }); - var viewToggle = document.getElementById('hr-view-toggle'); + var viewToggle = root.querySelector('#hr-view-toggle'); if (viewToggle) { - viewToggle.style.display = (tab === 'employees') ? '' : 'none'; + viewToggle.style.display = (tab === 'employees') ? 'flex' : 'none'; } if (tab === 'hiring') hrHiringInit(); if (tab === 'employees') hrFilterEmployees(); @@ -116,10 +122,7 @@ function hrFilterEmployees() { } } -/* Деревовидний пікер відділів (фільтр HR-порталу). Дерево передається один - раз через {{ hr_department_tree|json_script:"hr-dept-tree-data" }} — - будується лениво при першому відкритті, щоб не залежати від моменту - ініціалізації (важливо для HTMX-фрагментів і жорсткого перезавантаження). */ +/* Деревовидный пикер отделов: фильтр сотрудников и форма найма. */ (function () { function collectIds(node, acc) { acc.push(String(node.id)); @@ -127,21 +130,36 @@ function hrFilterEmployees() { return acc; } - function selectDept(node) { - var input = document.getElementById('hr-department-select'); - var label = document.getElementById('hr-dept-picker-label'); - if (node) { - input.value = collectIds(node, []).join(','); - label.textContent = node.name; - } else { - input.value = ''; - label.textContent = 'Всі відділи'; + function buildPathLabel(node) { + var parts = []; + var current = node; + while (current) { + parts.unshift(current.name); + current = current.parentRef; } - closeDeptPicker(); - hrFilterEmployees(); + return parts.join(' → '); } - function renderNode(node, container, depth) { + function attachParentRefs(nodes, parent) { + nodes.forEach(function (node) { + node.parentRef = parent || null; + if (node.children && node.children.length) { + attachParentRefs(node.children, node); + } + }); + } + + function readTree(treeDataId) { + var dataEl = document.getElementById(treeDataId); + var tree = []; + if (dataEl) { + try { tree = JSON.parse(dataEl.textContent) || []; } catch (e) { tree = []; } + } + attachParentRefs(tree, null); + return tree; + } + + function renderNode(node, container, depth, picker) { var row = document.createElement('div'); row.style.display = 'flex'; row.style.alignItems = 'center'; @@ -171,7 +189,7 @@ function hrFilterEmployees() { label.style.color = 'var(--color-foreground)'; row.appendChild(label); - if (node.count) { + if (picker.showCounts && node.count) { var count = document.createElement('span'); count.textContent = node.count; count.style.fontSize = '0.6875rem'; @@ -185,7 +203,7 @@ function hrFilterEmployees() { childrenWrap = document.createElement('div'); childrenWrap.style.display = 'none'; container.appendChild(childrenWrap); - node.children.forEach(function (child) { renderNode(child, childrenWrap, depth + 1); }); + node.children.forEach(function (child) { renderNode(child, childrenWrap, depth + 1, picker); }); toggle.addEventListener('click', function (e) { e.stopPropagation(); @@ -197,62 +215,118 @@ function hrFilterEmployees() { label.addEventListener('click', function (e) { e.stopPropagation(); - selectDept(node); + picker.selectNode(node); }); } - function buildPanel() { - var panel = document.getElementById('hr-dept-picker-panel'); - if (!panel || panel.dataset.built) return; - panel.dataset.built = '1'; - - var allRow = document.createElement('div'); - allRow.textContent = 'Всі відділи'; - allRow.style.padding = '0.375rem 0.5rem'; - allRow.style.borderRadius = 'calc(var(--radius) - 2px)'; - allRow.style.cursor = 'pointer'; - allRow.style.fontSize = '0.8125rem'; - allRow.style.fontWeight = '500'; - allRow.style.color = 'var(--color-foreground)'; - allRow.style.marginBottom = '0.25rem'; - allRow.onmouseover = function () { allRow.style.background = 'var(--color-muted)'; }; - allRow.onmouseout = function () { allRow.style.background = ''; }; - allRow.addEventListener('click', function (e) { - e.stopPropagation(); - selectDept(null); - }); - panel.appendChild(allRow); - - var dataEl = document.getElementById('hr-dept-tree-data'); - var tree = []; - if (dataEl) { - try { tree = JSON.parse(dataEl.textContent) || []; } catch (e) { tree = []; } - } - tree.forEach(function (node) { renderNode(node, panel, 0); }); - } - - function closeDeptPicker() { - var panel = document.getElementById('hr-dept-picker-panel'); - if (panel) panel.style.display = 'none'; - } - - window.hrDeptPickerToggle = function (e) { - e.stopPropagation(); - buildPanel(); - var panel = document.getElementById('hr-dept-picker-panel'); - if (!panel) return; - var opening = panel.style.display === 'none'; - panel.style.display = opening ? 'block' : 'none'; - if (opening) { - document.addEventListener('click', function onDocClick(ev) { - var picker = document.querySelector('.hr-dept-picker'); - if (picker && !picker.contains(ev.target)) { - closeDeptPicker(); - document.removeEventListener('click', onDocClick); + function createDeptPicker(config) { + var picker = { + inputId: config.inputId, + labelId: config.labelId, + panelId: config.panelId, + treeDataId: config.treeDataId || 'hr-dept-tree-data', + allLabel: config.allLabel || 'Всі відділи', + emptyLabel: config.emptyLabel || '—', + mode: config.mode || 'filter', + showCounts: config.showCounts !== false, + showAllOption: config.showAllOption !== false, + onChange: config.onChange || function () {}, + selectNode: function (node) { + var input = document.getElementById(picker.inputId); + var label = document.getElementById(picker.labelId); + if (!input || !label) return; + if (node) { + input.value = picker.mode === 'single' + ? String(node.id) + : collectIds(node, []).join(','); + label.textContent = picker.mode === 'single' + ? buildPathLabel(node) + : node.name; + } else { + input.value = ''; + label.textContent = picker.mode === 'single' + ? (label.getAttribute('data-empty-label') || picker.emptyLabel) + : picker.allLabel; } - }); - } - }; + picker.close(); + picker.onChange(node); + }, + close: function () { + var panel = document.getElementById(picker.panelId); + if (panel) panel.style.display = 'none'; + }, + buildPanel: function () { + var panel = document.getElementById(picker.panelId); + if (!panel || panel.dataset.built) return; + panel.dataset.built = '1'; + panel.innerHTML = ''; + + if (picker.showAllOption) { + var allRow = document.createElement('div'); + allRow.textContent = picker.mode === 'single' ? picker.emptyLabel : picker.allLabel; + allRow.style.padding = '0.375rem 0.5rem'; + allRow.style.borderRadius = 'calc(var(--radius) - 2px)'; + allRow.style.cursor = 'pointer'; + allRow.style.fontSize = '0.8125rem'; + allRow.style.fontWeight = '500'; + allRow.style.color = 'var(--color-foreground)'; + allRow.style.marginBottom = '0.25rem'; + allRow.onmouseover = function () { allRow.style.background = 'var(--color-muted)'; }; + allRow.onmouseout = function () { allRow.style.background = ''; }; + allRow.addEventListener('click', function (e) { + e.stopPropagation(); + picker.selectNode(null); + }); + panel.appendChild(allRow); + } + + readTree(picker.treeDataId).forEach(function (node) { + renderNode(node, panel, 0, picker); + }); + }, + toggle: function (e) { + if (e) e.stopPropagation(); + picker.buildPanel(); + var panel = document.getElementById(picker.panelId); + if (!panel) return; + var opening = panel.style.display === 'none'; + panel.style.display = opening ? 'block' : 'none'; + if (opening) { + document.addEventListener('click', function onDocClick(ev) { + var wrap = panel.closest('.hr-dept-picker'); + if (wrap && !wrap.contains(ev.target)) { + picker.close(); + document.removeEventListener('click', onDocClick); + } + }); + } + }, + }; + return picker; + } + + var hrFilterDeptPicker = createDeptPicker({ + inputId: 'hr-department-select', + labelId: 'hr-dept-picker-label', + panelId: 'hr-dept-picker-panel', + mode: 'filter', + allLabel: 'Всі відділи', + onChange: function () { hrFilterEmployees(); }, + }); + + var hrHiringDeptPicker = createDeptPicker({ + inputId: 'hr-hiring-department-select', + labelId: 'hr-hiring-dept-picker-label', + panelId: 'hr-hiring-dept-picker-panel', + mode: 'single', + showCounts: false, + showAllOption: true, + emptyLabel: '—', + onChange: function () {}, + }); + + window.hrDeptPickerToggle = hrFilterDeptPicker.toggle; + window.hrHiringDeptPickerToggle = hrHiringDeptPicker.toggle; })(); function hrHiringLocToggle(locationType) { @@ -277,7 +351,13 @@ function hrHiringLocToggle(locationType) { if (deptSelect) { deptSelect.required = isOfficeLike; - if (isPharmacy) deptSelect.value = ''; + if (isPharmacy) { + deptSelect.value = ''; + var deptLabel = document.getElementById('hr-hiring-dept-picker-label'); + if (deptLabel) { + deptLabel.textContent = deptLabel.getAttribute('data-empty-label') || '—'; + } + } } if (pharmSelect) { pharmSelect.required = isPharmacy; @@ -346,30 +426,88 @@ function hrHiringInit() { hrHiringLocToggle(sel.value); } +/** Показати форму кадрової заявки на вкладці «Заявки». */ +function hrShowRequestForm(type, label) { + hrTabSwitch('requests'); + var wrap = document.getElementById('hr-request-form-wrap'); + var input = document.getElementById('hr-request-type'); + var labelEl = document.getElementById('hr-request-type-label'); + if (wrap) wrap.style.display = ''; + if (input && type) { + input.value = type; + hrRequestTypeToggle(type); + } + if (labelEl && label) labelEl.textContent = label; +} + +function hrHideRequestForm() { + var wrap = document.getElementById('hr-request-form-wrap'); + if (wrap) wrap.style.display = 'none'; +} + +function hrShowVacancyForm() { + var wrap = document.getElementById('hr-vacancy-form-wrap'); + if (wrap) wrap.style.display = 'block'; + var sel = document.getElementById('hr-vacancy-loc-type'); + if (sel) hrVacancyLocToggle(sel.value || 'pharmacy'); +} + +function hrHideVacancyForm() { + var wrap = document.getElementById('hr-vacancy-form-wrap'); + if (wrap) wrap.style.display = 'none'; +} + +function hrToggleVacancyForm() { + var wrap = document.getElementById('hr-vacancy-form-wrap'); + if (!wrap) return; + if (wrap.style.display === 'none' || !wrap.style.display) { + hrShowVacancyForm(); + } else { + hrHideVacancyForm(); + } +} + +function hrVacancyLocToggle(locType) { + var isPharmacy = locType === 'pharmacy'; + var pharm = document.getElementById('hr-vacancy-pharmacy-field'); + var dept = document.getElementById('hr-vacancy-department-field'); + if (pharm) pharm.style.display = isPharmacy ? '' : 'none'; + if (dept) dept.style.display = isPharmacy ? 'none' : ''; +} + +function hrRequestTypeToggle(type) { + var needsDates = ['vacation', 'additional_vacation', 'sick_leave', 'day_off', 'substitution'].indexOf(type) !== -1; + var datesWrap = document.getElementById('hr-request-dates-wrap'); + var dateToWrap = document.getElementById('hr-request-date-to-wrap'); + var dateFrom = document.getElementById('hr-request-date-from'); + var dateTo = document.getElementById('hr-request-date-to'); + if (datesWrap) datesWrap.style.display = needsDates ? '' : 'none'; + if (dateToWrap) dateToWrap.style.display = needsDates ? '' : 'none'; + if (dateFrom) dateFrom.required = needsDates; + if (dateTo) dateTo.required = needsDates; +} + /** Ініціалізація HR-порталу після завантаження / HTMX-swap. */ function hrPortalInit() { - var root = document.querySelector('.page-content[data-hr-active-tab]'); - var activeTab = root ? root.getAttribute('data-hr-active-tab') : ''; - if (activeTab && activeTab !== 'employees') { - hrTabSwitch(activeTab); - } else { - // Сбрасываем визуальное состояние вкладок на «Сотрудники» - document.querySelectorAll('.hr-tab-btn').forEach(function (btn) { - var active = btn.getAttribute('data-tab') === 'employees'; - btn.classList.toggle('hr-tab-active', active); - btn.style.background = active ? 'var(--color-card)' : 'transparent'; - btn.style.color = active ? 'var(--color-card-foreground)' : 'var(--color-muted-foreground)'; - btn.style.boxShadow = active ? '0 1px 2px rgb(0 0 0/0.08)' : ''; - }); - document.querySelectorAll('.hr-panel').forEach(function (panel) { - panel.style.display = panel.id === 'hr-panel-employees' ? '' : 'none'; - }); - var viewToggle = document.getElementById('hr-view-toggle'); - if (viewToggle) viewToggle.style.display = ''; - } + var root = hrPortalRoot(); + if (!root) return; + var activeTab = root.getAttribute('data-hr-active-tab') || 'employees'; + hrTabSwitch(activeTab); - var activeLoc = document.querySelector('.hr-loc-btn.hr-loc-active'); - // За замовчуванням — «Всі» (порожній data-loc), не «Аптека». + var activeLoc = root.querySelector('.hr-loc-btn.hr-loc-active'); + var reqType = root.querySelector('#hr-request-type'); + if (reqType) { + hrRequestTypeToggle(reqType.value); + var labelEl = root.querySelector('#hr-request-type-label'); + if (labelEl && !labelEl.textContent.trim()) { + var btn = root.querySelector('.hr-request-type-btn[data-request-type="' + reqType.value + '"]'); + if (btn) labelEl.textContent = btn.getAttribute('data-request-label') || btn.textContent.trim(); + } + } + if (root.getAttribute('data-hr-request-form-open') === '1') { + var formWrap = root.querySelector('#hr-request-form-wrap'); + if (formWrap) formWrap.style.display = 'block'; + } var loc = activeLoc ? activeLoc.getAttribute('data-loc') : ''; if (!activeLoc) { hrLocFilter(''); @@ -377,8 +515,8 @@ function hrPortalInit() { hrFilterListPosts(loc || ''); hrFilterEmployees(); } - var hiringPanel = document.getElementById('hr-panel-hiring'); - if (hiringPanel && hiringPanel.style.display !== 'none') { + var hiringPanel = root.querySelector('#hr-panel-hiring'); + if (hiringPanel && hiringPanel.style.display !== 'none' && activeTab === 'hiring') { hrHiringInit(); } } @@ -390,7 +528,8 @@ if (document.readyState === 'loading') { } // Слушаем на document: скрипт в , document.body ещё может быть null. document.addEventListener('htmx:afterSwap', function (ev) { - if (ev.target && ev.target.id === 'main-content') { + var target = ev.detail && ev.detail.target; + if (target && target.id === 'main-content') { hrPortalInit(); } }); diff --git a/templates/pages/hr-portal.html b/templates/pages/hr-portal.html index d7ab680..e9bc98f 100644 --- a/templates/pages/hr-portal.html +++ b/templates/pages/hr-portal.html @@ -1,11 +1,18 @@ {% load common_tags %} {% load i18n %} {% trans "Должность не указана" as no_post %} -
+
+{% with hr_tab=hr_active_tab|default:'employees' %}

{% trans "HR Портал" %}

-

{% trans "Управление персоналом и кадровые процессы" %}

+

+ {% if can_manage_hiring %} + {% trans "Управление персоналом и кадровые процессы" %} + {% else %} + {% trans "Справочник сотрудников и кадровые заявки" %} + {% endif %} +

{% if hiring_just_created %} @@ -36,7 +43,21 @@
{% endif %} - + {% if hr_request_created %} +
+ {% trans "Заявку подано" %}: {{ hr_request_created.summary }} +
+ {% endif %} + + {% if hr_request_errors %} +
+
{% trans "Не удалось подать заявку" %}
+ {% for err in hr_request_errors %}
{{ err }}
{% endfor %} +
+ {% endif %} + + {% if can_manage_hiring %} +
@@ -83,8 +104,8 @@
{% trans "Заявки на отпуск" %}
-
3
-
1 {% trans "ожидает решения" %}
+
{{ hr_pending_leave_count }}
+
{% trans "ожидают решения HR" %}
@@ -94,6 +115,7 @@
+ {% endif %}
@@ -116,19 +138,25 @@
- - - - - + + {% if can_manage_hiring %} + + {% endif %} + + {% if can_manage_hiring %} + + {% endif %} + {% if can_view_recruiting %} + + {% endif %}
-
+
-
+
- -
-