diff --git a/apps/employees/admin.py b/apps/employees/admin.py index 3c11921..a65f62f 100644 --- a/apps/employees/admin.py +++ b/apps/employees/admin.py @@ -8,7 +8,9 @@ from django.utils.translation import gettext_lazy as _ from unfold.admin import ModelAdmin, TabularInline from core.models import Directory, PortalAccess +from core.security import filter_portal_permissions_for_user, is_sensitive_portal_perm from .models import Employee, PermissionGroup, EmployeeDefaults, EmployeePermissionOverride +from .models import EmployeeHrRequest, LeaveEntitlement from apps.visit_manager.models import WorkSchedule, AttendanceLog @@ -96,9 +98,20 @@ class EmployeePermissionOverrideInline(TabularInline): def formfield_for_foreignkey(self, db_field, request, **kwargs): if db_field.name == 'permission': - kwargs['queryset'] = _portal_permissions_qs() + kwargs['queryset'] = filter_portal_permissions_for_user( + _portal_permissions_qs(), request.user, + ) return super().formfield_for_foreignkey(db_field, request, **kwargs) + def has_add_permission(self, request, obj=None): + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + class AttendanceLogInline(TabularInline): model = AttendanceLog @@ -212,6 +225,23 @@ class EmployeeAdmin(ModelAdmin): )) return fieldsets + def get_readonly_fields(self, request, obj=None): + readonly = list(super().get_readonly_fields(request, obj)) + if not request.user.is_superuser: + readonly.extend([ + 'groups', + 'ad_user_dn', 'ad_sync_status', 'ad_sync_error', + 'ad_last_sync', 'ad_sync_enabled', + ]) + return readonly + + def save_model(self, request, obj, form, change): + if not request.user.is_superuser and change: + previous = Employee.objects.filter(pk=obj.pk).prefetch_related('groups').first() + if previous: + obj.groups.set(previous.groups.all()) + super().save_model(request, obj, form, change) + # def permissions_summary(self, obj): # perms = obj.effective_permissions() # group = obj.permission_group.name if obj.permission_group_id else None @@ -336,7 +366,7 @@ class PermissionGroupAdminForm(forms.ModelForm): @admin.register(PermissionGroup) class PermissionGroupAdmin(ModelAdmin): form = PermissionGroupAdminForm - list_display = ('group', 'is_active', 'description') + list_display = ('group', 'is_active', 'description', 'portal_matrix_link') list_filter = ('is_active',) search_fields = ('group__name', 'description') @@ -346,9 +376,30 @@ class PermissionGroupAdmin(ModelAdmin): }), ) + @admin.display(description=_('Матрица в портале')) + def portal_matrix_link(self, obj): + if not obj or not obj.group_id: + return '—' + from django.urls import reverse + from django.utils.html import format_html + url = reverse('user_group_access', kwargs={'group_id': obj.group_id}) + return format_html('{}', url, _('Открыть')) + + def get_readonly_fields(self, request, obj=None): + if request.user.is_superuser: + return () + return ('group', 'description', 'is_active', 'portal_permissions') + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + def save_model(self, request, obj, form, change): super().save_model(request, obj, form, change) - form.save_portal_permissions(obj) + if request.user.is_superuser: + form.save_portal_permissions(obj) @admin.register(EmployeePermissionOverride) class EmployeePermissionOverrideAdmin(ModelAdmin): @@ -384,5 +435,42 @@ class EmployeePermissionOverrideAdmin(ModelAdmin): def formfield_for_foreignkey(self, db_field, request, **kwargs): if db_field.name == 'permission': - kwargs['queryset'] = _portal_permissions_qs() + kwargs['queryset'] = filter_portal_permissions_for_user( + _portal_permissions_qs(), request.user, + ) return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def has_add_permission(self, request): + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + return request.user.is_superuser + + +@admin.register(LeaveEntitlement) +class LeaveEntitlementAdmin(ModelAdmin): + list_display = ('employee', 'year', 'annual_days', 'additional_days', 'sick_days') + list_filter = ('year',) + search_fields = ('employee__username', 'employee__last_name', 'employee__first_name') + autocomplete_fields = ('employee',) + readonly_fields = ('uuid', 'created_at', 'updated_at') + + +@admin.register(EmployeeHrRequest) +class EmployeeHrRequestAdmin(ModelAdmin): + list_display = ( + 'employee', 'request_type', 'substitute', 'date_from', 'date_to', + 'status', 'created_at', 'decided_by', + ) + list_filter = ('status', 'request_type', 'created_at') + search_fields = ( + 'employee__username', 'employee__last_name', 'employee__first_name', + 'note', 'decision_note', + ) + list_select_related = ('employee', 'decided_by', 'substitute') + autocomplete_fields = ('employee', 'decided_by', 'substitute') + readonly_fields = ('uuid', 'created_at', 'updated_at', 'decided_at') + date_hierarchy = 'created_at' diff --git a/apps/employees/management/commands/create_password_invite.py b/apps/employees/management/commands/create_password_invite.py new file mode 100644 index 0000000..c69001f --- /dev/null +++ b/apps/employees/management/commands/create_password_invite.py @@ -0,0 +1,38 @@ +"""Створити посилання для встановлення пароля (існуючі або нові співробітники).""" +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +from apps.employees.services.password_invite import build_invite_absolute_url, create_password_invite + +Employee = get_user_model() + + +class Command(BaseCommand): + help = 'Створити одноразове посилання для встановлення пароля порталу' + + def add_arguments(self, parser): + parser.add_argument('--username', help='Логін співробітника') + parser.add_argument( + '--all-unusable', + action='store_true', + help='Для всіх активних з непридатним паролем (імпорт AD тощо)', + ) + parser.add_argument('--days', type=int, default=7, help='Термін дії посилання (дні)') + + def handle(self, *args, **options): + if options['username']: + employees = Employee.objects.filter(username=options['username'], is_active=True) + elif options['all_unusable']: + employees = [e for e in Employee.objects.filter(is_active=True) if not e.has_usable_password()] + else: + self.stderr.write('Вкажіть --username або --all-unusable') + return + + if not employees: + self.stderr.write('Співробітників не знайдено') + return + + for employee in employees: + invite = create_password_invite(employee, ttl_days=options['days']) + url = build_invite_absolute_url(None, invite) + self.stdout.write(f'{employee.username}\t{url}') diff --git a/apps/employees/management/commands/create_test_portal_users.py b/apps/employees/management/commands/create_test_portal_users.py new file mode 100644 index 0000000..caedfbc --- /dev/null +++ b/apps/employees/management/commands/create_test_portal_users.py @@ -0,0 +1,99 @@ +"""Создание тестовых учёток: обычный сотрудник и HR.""" +from datetime import date + +from django.contrib.auth.models import Group +from django.core.management.base import BaseCommand, CommandError + +from apps.employees.models import Employee +from apps.locations.models import Pharmacy +from core.models import Directory + + +class Command(BaseCommand): + help = 'Create or reset test.portal (pharmacy) and test.hr (HR portal) employees' + + def add_arguments(self, parser): + parser.add_argument( + '--password-user', + default='TestUser2026!', + help='Password for test.portal', + ) + parser.add_argument( + '--password-hr', + default='TestHr2026!', + help='Password for test.hr', + ) + + def handle(self, *args, **options): + pharmacy = Pharmacy.objects.filter(is_active=True).order_by('pharmacy_number').first() + if not pharmacy: + raise CommandError('No active pharmacy — run seed/migrations first') + + department = Directory.objects.filter( + directory_type__code='department', is_active=True, + ).first() + post = Directory.objects.filter(directory_type__code='post', is_active=True).first() + hr_group = Group.objects.get(name='HR') + + user, created_u = Employee.objects.get_or_create( + username='test.portal', + defaults={ + 'first_name': 'Олена', + 'last_name': 'Тестова', + 'middle_name': 'Іванівна', + 'email': 'test.portal@local.domain', + 'hire_date': date(2025, 1, 15), + 'user_location_type': 'pharmacy', + 'user_pharmacy': pharmacy, + 'post': post, + 'is_active': True, + 'is_staff': False, + 'mattermost_sync_enabled': False, + 'ad_sync_enabled': False, + }, + ) + user.set_password(options['password_user']) + user.groups.clear() + user.is_staff = False + user.totp_enabled = False + user.totp_secret = '' + user.totp_backup_codes = [] + user.save() + + hr, created_h = Employee.objects.get_or_create( + username='test.hr', + defaults={ + 'first_name': 'Ганна', + 'last_name': 'Кадрова', + 'middle_name': 'Петрівна', + 'email': 'test.hr@local.domain', + 'hire_date': date(2024, 3, 1), + 'user_location_type': 'office', + 'department': department, + 'post': post, + 'is_active': True, + 'is_staff': False, + 'mattermost_sync_enabled': False, + 'ad_sync_enabled': False, + }, + ) + hr.set_password(options['password_hr']) + hr.groups.set([hr_group]) + hr.is_staff = False + hr.totp_enabled = False + hr.totp_secret = '' + hr.totp_backup_codes = [] + hr.save() + + action_u = 'created' if created_u else 'updated' + action_h = 'created' if created_h else 'updated' + self.stdout.write(self.style.SUCCESS( + f'test.portal ({action_u}): pharmacy №{pharmacy.pharmacy_number}, no special groups' + )) + self.stdout.write(self.style.SUCCESS( + f'test.hr ({action_h}): office, group HR → /hr-portal/' + )) + self.stdout.write('') + self.stdout.write('Logins and passwords printed above in your terminal only.') + self.stdout.write(f' test.portal / {options["password_user"]}') + self.stdout.write(f' test.hr / {options["password_hr"]}') diff --git a/apps/employees/middleware.py b/apps/employees/middleware.py index 55657ae..a2a56ce 100644 --- a/apps/employees/middleware.py +++ b/apps/employees/middleware.py @@ -1,15 +1,36 @@ -"""Принуждение 2FA для staff-пользователей на всём портале и в /admin/. +"""Принуждение 2FA на портале и в /admin/. -- staff без включённой 2FA → редирект на настройку (обязательное включение); -- staff с 2FA, но не прошедший проверку в текущей сессии → редирект на ввод кода. - -Покрывает и Django-админку: вход в /admin/ создаёт обычную сессию, но доступ -к страницам /admin/ блокируется, пока сессия не помечена как 2fa_verified. +Staff и IT-разделы (AD, IT-отдел) — обязательно. HR и остальные — по желанию +(opt-in после включения TOTP). """ from django.shortcuts import redirect from django.urls import reverse +from core.security import user_requires_mandatory_2fa + + +def _twofa_required(user) -> bool: + return user_requires_mandatory_2fa(user) + + +def _own_profile_settings_allowed(request, user) -> bool: + """Доступ к своим настройкам до включения 2FA (вкладка «Настройки» профиля).""" + if not user or not getattr(user, 'is_authenticated', False): + return False + own_path = f'/employees/{user.pk}' + if request.path.rstrip('/') != own_path: + return False + if request.method == 'GET' and request.GET.get('tab') == 'settings': + return True + if request.method == 'POST': + if request.POST.get('profile_tab') == 'settings': + return True + if request.POST.get('return_to') == 'profile': + return True + return False + + class TwoFactorMiddleware: # Пути, доступные без пройденной 2FA: сам процесс входа/настройки и статика. # /api/ и /media/ НЕ включены — staff-сессия без 2FA не должна дотянуться до @@ -17,7 +38,7 @@ class TwoFactorMiddleware: # staff-сессии, поэтому их этот гейт не трогает). ALLOWED_PREFIXES = ( '/2fa/', '/logout/', '/accounts/login/', '/login/', - '/admin/login/', '/admin/logout/', '/i18n/', + '/set-password/', '/admin/login/', '/admin/logout/', '/i18n/', '/static/', ) @@ -28,9 +49,13 @@ class TwoFactorMiddleware: user = getattr(request, 'user', None) path = request.path - if (user and user.is_authenticated and user.is_staff + if (_twofa_required(user) and not path.startswith(self.ALLOWED_PREFIXES)): + if path.rstrip('/') == '/settings': + return self.get_response(request) if not user.totp_enabled: + if _own_profile_settings_allowed(request, user): + return self.get_response(request) return redirect('twofa_setup') if not request.session.get('2fa_verified'): return redirect(f"{reverse('twofa_verify')}?next={path}") diff --git a/apps/employees/migrations/0014_msorter_groups.py b/apps/employees/migrations/0014_msorter_groups.py new file mode 100644 index 0000000..3e94f7a --- /dev/null +++ b/apps/employees/migrations/0014_msorter_groups.py @@ -0,0 +1,37 @@ +from django.db import migrations + + +GROUPS = [ + 'Почта', + 'Администратор почты', +] + + +def create_groups(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + PermissionGroup = apps.get_model('employees', 'PermissionGroup') + for name in GROUPS: + group, _ = Group.objects.get_or_create(name=name) + PermissionGroup.objects.get_or_create( + group=group, + defaults={ + 'description': f'Доступ к MSORTER: {name}', + 'is_active': True, + }, + ) + + +def delete_groups(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + Group.objects.filter(name__in=GROUPS).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0013_dislocation_column_presets'), + ] + + operations = [ + migrations.RunPython(create_groups, delete_groups), + ] diff --git a/apps/employees/migrations/0015_password_setup_invite.py b/apps/employees/migrations/0015_password_setup_invite.py new file mode 100644 index 0000000..d07416c --- /dev/null +++ b/apps/employees/migrations/0015_password_setup_invite.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.3 on 2026-08-31 13:43 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0014_msorter_groups'), + ] + + operations = [ + migrations.CreateModel( + name='PasswordSetupInvite', + 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)), + ('token', models.CharField(db_index=True, max_length=64, unique=True, verbose_name='Токен')), + ('expires_at', models.DateTimeField(verbose_name='Действует до')), + ('used_at', models.DateTimeField(blank=True, null=True, verbose_name='Использовано')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='password_invites_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='password_invites', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), + ], + options={ + 'verbose_name': 'Приглашение: установка пароля', + 'verbose_name_plural': 'Приглашения: установка пароля', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/employees/migrations/0016_employee_hr_request.py b/apps/employees/migrations/0016_employee_hr_request.py new file mode 100644 index 0000000..cdbfed0 --- /dev/null +++ b/apps/employees/migrations/0016_employee_hr_request.py @@ -0,0 +1,60 @@ +# Generated by Django 6.0.3 on 2026-09-01 10:18 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0015_password_setup_invite'), + ] + + operations = [ + migrations.CreateModel( + name='EmployeeHrRequest', + 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)), + ('request_type', models.CharField(choices=[('vacation', 'Отпуск'), ('additional_vacation', 'Дополнительный отпуск'), ('sick_leave', 'Больничный'), ('day_off', 'Отгул'), ('certificate', 'Справка')], max_length=32, verbose_name='Тип')), + ('date_from', models.DateField(blank=True, null=True, verbose_name='Дата с')), + ('date_to', models.DateField(blank=True, null=True, verbose_name='Дата по')), + ('note', models.TextField(blank=True, verbose_name='Комментарий')), + ('status', models.CharField(choices=[('pending', 'На рассмотрении'), ('approved', 'Утверждено'), ('rejected', 'Отклонено'), ('cancelled', 'Отменено')], default='pending', max_length=16, verbose_name='Статус')), + ('decided_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата решения')), + ('decision_note', models.TextField(blank=True, verbose_name='Комментарий HR')), + ('decided_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hr_requests_decided', to=settings.AUTH_USER_MODEL, verbose_name='Решение принял')), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hr_requests', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), + ], + options={ + 'verbose_name': 'Кадровая заявка', + 'verbose_name_plural': 'Кадровые заявки', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['employee', 'status'], name='employees_e_employe_283616_idx'), models.Index(fields=['status', 'request_type'], name='employees_e_status_a446a1_idx')], + }, + ), + migrations.CreateModel( + name='LeaveEntitlement', + 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)), + ('year', models.PositiveIntegerField(verbose_name='Год')), + ('annual_days', models.PositiveSmallIntegerField(default=24, verbose_name='Основной отпуск, дн.')), + ('additional_days', models.PositiveSmallIntegerField(default=7, verbose_name='Доп. отпуск, дн.')), + ('sick_days', models.PositiveSmallIntegerField(default=30, verbose_name='Больничные, дн.')), + ('employee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='leave_entitlements', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), + ], + options={ + 'verbose_name': 'Норма отпуска', + 'verbose_name_plural': 'Нормы отпусков', + 'ordering': ['-year', 'employee__last_name'], + 'constraints': [models.UniqueConstraint(fields=('employee', 'year'), name='unique_leave_entitlement_per_year')], + }, + ), + ] diff --git a/apps/employees/migrations/0017_group_section_access.py b/apps/employees/migrations/0017_group_section_access.py new file mode 100644 index 0000000..7018e77 --- /dev/null +++ b/apps/employees/migrations/0017_group_section_access.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.3 on 2026-09-08 16:55 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('employees', '0016_employee_hr_request'), + ] + + operations = [ + migrations.CreateModel( + name='GroupSectionAccess', + 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)), + ('section_key', models.CharField(db_index=True, max_length=64, verbose_name='Раздел')), + ('can_create', models.BooleanField(default=True, verbose_name='Создать')), + ('can_update', models.BooleanField(default=True, verbose_name='Обновить')), + ('can_delete', models.BooleanField(default=True, verbose_name='Удалить')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='section_access', to='auth.group', verbose_name='Группа')), + ], + options={ + 'verbose_name': 'Доступ группы к разделу', + 'verbose_name_plural': 'Доступы групп к разделам', + 'ordering': ['group__name', 'section_key'], + 'constraints': [models.UniqueConstraint(fields=('group', 'section_key'), name='uniq_group_section_access')], + }, + ), + ] diff --git a/apps/employees/migrations/0018_seed_group_section_access.py b/apps/employees/migrations/0018_seed_group_section_access.py new file mode 100644 index 0000000..eedc41b --- /dev/null +++ b/apps/employees/migrations/0018_seed_group_section_access.py @@ -0,0 +1,57 @@ +# Seed GroupSectionAccess from existing core.access_* on groups (CUD=True). + +from django.db import migrations + + +def seed_from_access_perms(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + Permission = apps.get_model('auth', 'Permission') + ContentType = apps.get_model('contenttypes', 'ContentType') + GroupSectionAccess = apps.get_model('employees', 'GroupSectionAccess') + + ct = ContentType.objects.filter(app_label='core', model='portalaccess').first() + if ct is None: + return + + access_perms = { + p.codename: p + for p in Permission.objects.filter(content_type=ct, codename__startswith='access_') + } + if not access_perms: + return + + for group in Group.objects.all().iterator(): + codenames = set( + group.permissions.filter(content_type=ct, codename__startswith='access_') + .values_list('codename', flat=True) + ) + for codename in codenames: + if not codename.startswith('access_'): + continue + section_key = codename[len('access_'):] + GroupSectionAccess.objects.update_or_create( + group_id=group.pk, + section_key=section_key, + defaults={ + 'can_create': True, + 'can_update': True, + 'can_delete': True, + }, + ) + + +def unseed(apps, schema_editor): + GroupSectionAccess = apps.get_model('employees', 'GroupSectionAccess') + GroupSectionAccess.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0017_group_section_access'), + ('core', '0010_seed_portal_section_perms'), + ] + + operations = [ + migrations.RunPython(seed_from_access_perms, unseed), + ] diff --git a/apps/employees/migrations/0019_hr_request_substitution.py b/apps/employees/migrations/0019_hr_request_substitution.py new file mode 100644 index 0000000..d575841 --- /dev/null +++ b/apps/employees/migrations/0019_hr_request_substitution.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.3 on 2026-09-08 19:37 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0018_seed_group_section_access'), + ] + + operations = [ + migrations.AddField( + model_name='employeehrrequest', + name='substitute', + field=models.ForeignKey(blank=True, help_text='Сотрудник, которого просят выйти на подмену', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hr_substitution_requests', to=settings.AUTH_USER_MODEL, verbose_name='Кто подменяет'), + ), + migrations.AlterField( + model_name='employeehrrequest', + name='request_type', + field=models.CharField(choices=[('vacation', 'Отпуск'), ('additional_vacation', 'Дополнительный отпуск'), ('sick_leave', 'Больничный'), ('day_off', 'Отгул'), ('certificate', 'Справка'), ('substitution', 'Подмена')], max_length=32, verbose_name='Тип'), + ), + ] diff --git a/apps/employees/migrations/0020_alter_employeehrrequest_substitute.py b/apps/employees/migrations/0020_alter_employeehrrequest_substitute.py new file mode 100644 index 0000000..17d860c --- /dev/null +++ b/apps/employees/migrations/0020_alter_employeehrrequest_substitute.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.3 on 2026-09-09 14:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0019_hr_request_substitution'), + ] + + operations = [ + migrations.AlterField( + model_name='employeehrrequest', + name='substitute', + field=models.ForeignKey(blank=True, help_text='Заполняется, когда сотрудник берёт открытый запрос на подмену', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hr_substitution_requests', to=settings.AUTH_USER_MODEL, verbose_name='Кто подменяет'), + ), + ] diff --git a/apps/employees/models/__init__.py b/apps/employees/models/__init__.py index 0443f65..a952bab 100644 --- a/apps/employees/models/__init__.py +++ b/apps/employees/models/__init__.py @@ -1,3 +1,5 @@ from .base import Employee, EmployeeManager from .defaults import EmployeeDefaults -from .permissions import PermissionGroup, EmployeePermissionOverride +from .permissions import PermissionGroup, EmployeePermissionOverride, GroupSectionAccess +from .password_invite import PasswordSetupInvite +from .hr_request import EmployeeHrRequest, LeaveEntitlement diff --git a/apps/employees/models/hr_request.py b/apps/employees/models/hr_request.py new file mode 100644 index 0000000..09a439f --- /dev/null +++ b/apps/employees/models/hr_request.py @@ -0,0 +1,178 @@ +"""Кадровые заявки сотрудников: отпуск, больничный, справки.""" +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from core.models import BaseModel + + +class LeaveEntitlement(BaseModel): + """Норма дней отпуска/больничных на год (если нет записи — дефолты из сервиса).""" + + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='leave_entitlements', + verbose_name=_('Сотрудник'), + ) + year = models.PositiveIntegerField(_('Год')) + annual_days = models.PositiveSmallIntegerField(_('Основной отпуск, дн.'), default=24) + additional_days = models.PositiveSmallIntegerField(_('Доп. отпуск, дн.'), default=7) + sick_days = models.PositiveSmallIntegerField(_('Больничные, дн.'), default=30) + + class Meta: + verbose_name = _('Норма отпуска') + verbose_name_plural = _('Нормы отпусков') + constraints = [ + models.UniqueConstraint(fields=['employee', 'year'], name='unique_leave_entitlement_per_year'), + ] + ordering = ['-year', 'employee__last_name'] + + def __str__(self): + return f'{self.employee} — {self.year}' + + +class EmployeeHrRequest(BaseModel): + REQUEST_TYPES = [ + ('vacation', _('Отпуск')), + ('additional_vacation', _('Дополнительный отпуск')), + ('sick_leave', _('Больничный')), + ('day_off', _('Отгул')), + ('certificate', _('Справка')), + ('substitution', _('Подмена')), + ] + STATUS_CHOICES = [ + ('pending', _('На рассмотрении')), + ('approved', _('Утверждено')), + ('rejected', _('Отклонено')), + ('cancelled', _('Отменено')), + ] + DATE_REQUIRED_TYPES = { + 'vacation', 'additional_vacation', 'sick_leave', 'day_off', 'substitution', + } + + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='hr_requests', + verbose_name=_('Сотрудник'), + ) + request_type = models.CharField(_('Тип'), max_length=32, choices=REQUEST_TYPES) + date_from = models.DateField(_('Дата с'), null=True, blank=True) + date_to = models.DateField(_('Дата по'), null=True, blank=True) + note = models.TextField(_('Комментарий'), blank=True) + substitute = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='hr_substitution_requests', + verbose_name=_('Кто подменяет'), + help_text=_('Заполняется, когда сотрудник берёт открытый запрос на подмену'), + ) + status = models.CharField( + _('Статус'), max_length=16, choices=STATUS_CHOICES, default='pending', + ) + decided_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='hr_requests_decided', + verbose_name=_('Решение принял'), + ) + decided_at = models.DateTimeField(_('Дата решения'), null=True, blank=True) + decision_note = models.TextField(_('Комментарий HR'), blank=True) + + class Meta: + verbose_name = _('Кадровая заявка') + verbose_name_plural = _('Кадровые заявки') + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['employee', 'status']), + models.Index(fields=['status', 'request_type']), + ] + + def __str__(self): + return f'{self.employee} — {self.get_request_type_display()} ({self.get_status_display()})' + + @property + def day_count(self) -> int: + if not self.date_from or not self.date_to: + return 0 + return (self.date_to - self.date_from).days + 1 + + @property + def summary(self) -> str: + label = self.get_request_type_display() + if self.request_type == 'substitution' and self.substitute_id: + who = self.substitute.get_full_name() or self.substitute.username + label = f'{label} ({who})' + if self.date_from and self.date_to: + if self.date_from == self.date_to: + return f'{label} {self.date_from.strftime("%d.%m.%Y")}' + return ( + f'{label} {self.date_from.strftime("%d.%m.%Y")}' + f' – {self.date_to.strftime("%d.%m.%Y")}' + ) + return label + + @property + def pharmacy_label(self) -> str: + """Краткое название аптеки заявителя (код/организация №).""" + pharmacy = getattr(self.employee, 'user_pharmacy', None) if self.employee_id else None + if pharmacy is None: + return '' + from apps.locations.models.base import pharmacy_object_code + code = pharmacy_object_code(pharmacy) + org = '' + if pharmacy.organization_id and pharmacy.organization: + org = pharmacy.organization.name or '' + if code and org: + return f'{code} ({org})' + if code: + return code + if pharmacy.pharmacy_number is not None: + head = f'Аптека №{pharmacy.pharmacy_number}' + return f'{org} {head}'.strip() if org else head + return str(pharmacy) + + @property + def pharmacy_address(self) -> str: + pharmacy = getattr(self.employee, 'user_pharmacy', None) if self.employee_id else None + if pharmacy is None: + return '' + return (pharmacy.get_full_address() or pharmacy.address or '').strip() + + @property + def pharmacy_place(self) -> str: + """Аптека + адрес одной строкой для списков.""" + label = self.pharmacy_label + addr = self.pharmacy_address + if label and addr: + return f'{label} — {addr}' + return label or addr + + @property + def status_label(self) -> str: + if self.request_type == 'substitution' and self.status == 'pending': + if self.substitute_id: + return str(_('Ожидает подтверждения')) + return str(_('Ищет подмену')) + return self.get_status_display() + + def clean(self): + super().clean() + if self.request_type in self.DATE_REQUIRED_TYPES: + if not self.date_from or not self.date_to: + raise ValidationError(_('Укажите даты начала и окончания.')) + if self.date_to < self.date_from: + raise ValidationError(_('Дата окончания не может быть раньше начала.')) + elif self.date_from and self.date_to and self.date_to < self.date_from: + raise ValidationError(_('Дата окончания не может быть раньше начала.')) + + if self.request_type == 'substitution' and self.substitute_id: + if self.substitute_id == self.employee_id: + raise ValidationError({'substitute': _('Нельзя указать себя для подмены.')}) diff --git a/apps/employees/models/password_invite.py b/apps/employees/models/password_invite.py new file mode 100644 index 0000000..6847a3a --- /dev/null +++ b/apps/employees/models/password_invite.py @@ -0,0 +1,39 @@ +"""Одноразовое приглашение для установки пароля портала.""" +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from core.models import BaseModel + + +class PasswordSetupInvite(BaseModel): + employee = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='password_invites', + verbose_name=_('Сотрудник'), + ) + token = models.CharField(_('Токен'), max_length=64, unique=True, db_index=True) + expires_at = models.DateTimeField(_('Действует до')) + used_at = models.DateTimeField(_('Использовано'), null=True, blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='password_invites_created', + verbose_name=_('Создал'), + ) + + class Meta: + verbose_name = _('Приглашение: установка пароля') + verbose_name_plural = _('Приглашения: установка пароля') + ordering = ['-created_at'] + + def __str__(self): + return f'Invite #{self.pk} → {self.employee_id}' + + @property + def is_active(self) -> bool: + return self.used_at is None and timezone.now() < self.expires_at diff --git a/apps/employees/models/permissions.py b/apps/employees/models/permissions.py index 232a3b4..f26e8cd 100644 --- a/apps/employees/models/permissions.py +++ b/apps/employees/models/permissions.py @@ -30,6 +30,38 @@ class PermissionGroup(BaseModel): ) +class GroupSectionAccess(BaseModel): + """ + Действия Create/Update/Delete по разделу портала для auth.Group. + + Просмотр раздела — Django permission core.access_ на группе. + """ + group = models.ForeignKey( + 'auth.Group', + on_delete=models.CASCADE, + related_name='section_access', + verbose_name=_('Группа'), + ) + section_key = models.CharField(_('Раздел'), max_length=64, db_index=True) + can_create = models.BooleanField(_('Создать'), default=True) + can_update = models.BooleanField(_('Обновить'), default=True) + can_delete = models.BooleanField(_('Удалить'), default=True) + + class Meta: + verbose_name = _('Доступ группы к разделу') + verbose_name_plural = _('Доступы групп к разделам') + ordering = ['group__name', 'section_key'] + constraints = [ + models.UniqueConstraint( + fields=['group', 'section_key'], + name='uniq_group_section_access', + ), + ] + + def __str__(self): + return f'{self.group.name}: {self.section_key}' + + class EmployeePermissionOverride(BaseModel): """ Индивидуальные override-ы прав для сотрудника. diff --git a/apps/employees/services/hr_requests.py b/apps/employees/services/hr_requests.py new file mode 100644 index 0000000..873eb5c --- /dev/null +++ b/apps/employees/services/hr_requests.py @@ -0,0 +1,314 @@ +"""Создание и обработка кадровых заявок сотрудников.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + +from django.core.exceptions import ValidationError +from django.db import transaction +from django.db.models import Q +from django.utils import timezone + +from apps.employees.models import Employee, EmployeeHrRequest, LeaveEntitlement + +DEFAULT_ANNUAL_DAYS = 24 +DEFAULT_ADDITIONAL_DAYS = 7 +DEFAULT_SICK_DAYS = 30 + +TYPE_TO_BALANCE_KEY = { + 'vacation': 'annual', + 'additional_vacation': 'additional', + 'sick_leave': 'sick', +} + + +@dataclass +class LeaveBalanceRow: + key: str + label: str + entitlement: int + used: int + pending: int + + @property + def remaining(self) -> int: + return max(0, self.entitlement - self.used) + + @property + def used_pct(self) -> int: + if self.entitlement <= 0: + return 0 + return min(100, round(self.used / self.entitlement * 100)) + + +def inclusive_days(date_from: date, date_to: date) -> int: + return (date_to - date_from).days + 1 + + +def _year_bounds(year: int) -> tuple[date, date]: + return date(year, 1, 1), date(year, 12, 31) + + +def _overlap_days(req: EmployeeHrRequest, year: int) -> int: + if not req.date_from or not req.date_to: + return 0 + y_start, y_end = _year_bounds(year) + start = max(req.date_from, y_start) + end = min(req.date_to, y_end) + if end < start: + return 0 + return inclusive_days(start, end) + + +def get_entitlement(employee: Employee, year: int) -> LeaveEntitlement: + row, _ = LeaveEntitlement.objects.get_or_create( + employee=employee, + year=year, + defaults={ + 'annual_days': DEFAULT_ANNUAL_DAYS, + 'additional_days': DEFAULT_ADDITIONAL_DAYS, + 'sick_days': DEFAULT_SICK_DAYS, + }, + ) + return row + + +def _sum_days(qs, year: int) -> int: + return sum(_overlap_days(r, year) for r in qs) + + +def build_leave_balance(employee: Employee, *, year: int | None = None) -> list[LeaveBalanceRow]: + year = year or timezone.localdate().year + ent = get_entitlement(employee, year) + approved = EmployeeHrRequest.objects.filter( + employee=employee, status='approved', + ).filter( + Q(date_from__year=year) | Q(date_to__year=year) | Q(date_from__isnull=True, created_at__year=year), + ) + pending = EmployeeHrRequest.objects.filter(employee=employee, status='pending') + + def bucket(request_type: str, entitlement: int, label: str, key: str) -> LeaveBalanceRow: + return LeaveBalanceRow( + key=key, + label=label, + entitlement=entitlement, + used=_sum_days(approved.filter(request_type=request_type), year), + pending=_sum_days(pending.filter(request_type=request_type), year), + ) + + return [ + bucket('vacation', ent.annual_days, 'Основной отпуск', 'annual'), + bucket('additional_vacation', ent.additional_days, 'Дополнительный отпуск', 'additional'), + bucket('sick_leave', ent.sick_days, 'Больничные', 'sick'), + ] + + +def next_pending_vacation(employee: Employee) -> EmployeeHrRequest | None: + today = timezone.localdate() + return ( + EmployeeHrRequest.objects.filter( + employee=employee, + request_type='vacation', + status='pending', + date_from__isnull=False, + date_from__gte=today, + ) + .order_by('date_from') + .first() + ) + + +def check_leave_balance( + employee: Employee, + request_type: str, + date_from: date | None, + date_to: date | None, + *, + exclude_request: EmployeeHrRequest | None = None, +) -> None: + """Не допускает превышения нормы: used + pending + новые дни (или used + дни при утверждении).""" + if request_type not in TYPE_TO_BALANCE_KEY or not date_from or not date_to: + return + + balance_key = TYPE_TO_BALANCE_KEY[request_type] + for year in range(date_from.year, date_to.year + 1): + rows = build_leave_balance(employee, year=year) + row = next(r for r in rows if r.key == balance_key) + new_days = _overlap_days( + EmployeeHrRequest(date_from=date_from, date_to=date_to), + year, + ) + if new_days <= 0: + continue + + if exclude_request: + committed = row.used + new_days + remaining = max(0, row.entitlement - row.used) + else: + committed = row.used + row.pending + new_days + remaining = max(0, row.entitlement - row.used - row.pending) + + if committed > row.entitlement: + raise ValidationError( + f'Превышен лимит «{row.label}» на {year} год: ' + f'запрошено {new_days} дн., доступно {remaining} дн.' + ) + + +def is_pharmacist_user(employee: Employee) -> bool: + """Группа «Фармацевт» без права управления HR (учитывает access-preview).""" + from apps.hiring.recruiting import user_is_pharmacist + return user_is_pharmacist(employee) + + +def available_hr_request_types(employee: Employee) -> list[tuple[str, str]]: + """Все типы заявок, включая отпуск и подмену.""" + return list(EmployeeHrRequest.REQUEST_TYPES) + + +def substitution_candidates(employee: Employee) -> list[Employee]: + """Устарело: выбор сотрудника при создании подмены не используется.""" + return [] + + +def open_substitution_requests(employee: Employee) -> list[EmployeeHrRequest]: + """Открытые запросы на подмену (чужие), на которые можно откликнуться.""" + return list( + EmployeeHrRequest.objects.filter( + request_type='substitution', + status='pending', + substitute__isnull=True, + ) + .exclude(employee=employee) + .select_related( + 'employee', + 'employee__user_pharmacy', + 'employee__user_pharmacy__organization', + ) + .order_by('date_from', 'created_at')[:50] + ) + + +def incoming_substitution_requests(employee: Employee) -> list[EmployeeHrRequest]: + """Совместимость: открытые заявки, доступные для подтверждения.""" + return open_substitution_requests(employee) + + +def create_hr_request( + *, + employee: Employee, + request_type: str, + date_from: date | None, + date_to: date | None, + note: str = '', + substitute: Employee | None = None, +) -> EmployeeHrRequest: + allowed = {c[0] for c in available_hr_request_types(employee)} + if request_type not in allowed: + raise ValidationError({'request_type': 'Недопустимый тип заявки.'}) + + # Подмену создаёт заявитель без выбора коллеги — запись открыта на портале. + if request_type == 'substitution': + substitute = None + + req = EmployeeHrRequest( + employee=employee, + request_type=request_type, + date_from=date_from, + date_to=date_to, + note=(note or '').strip(), + substitute=substitute, + ) + req.full_clean() + check_leave_balance(employee, request_type, date_from, date_to) + req.save() + return req + + +@transaction.atomic +def decide_hr_request( + req: EmployeeHrRequest, + *, + decided_by: Employee, + approve: bool, + decision_note: str = '', +) -> EmployeeHrRequest: + locked = EmployeeHrRequest.objects.select_for_update().get(pk=req.pk) + if locked.status != 'pending': + raise ValidationError('Заявка уже обработана.') + + if locked.request_type == 'substitution': + raise ValidationError( + 'Подмену подтверждает сотрудник через отклик на открытый запрос.' + ) + + if approve: + check_leave_balance( + locked.employee, + locked.request_type, + locked.date_from, + locked.date_to, + exclude_request=locked, + ) + locked.status = 'approved' if approve else 'rejected' + locked.decided_by = decided_by + locked.decided_at = timezone.now() + locked.decision_note = (decision_note or '').strip() + locked.save(update_fields=['status', 'decided_by', 'decided_at', 'decision_note', 'updated_at']) + return locked + + +@transaction.atomic +def confirm_substitution(req: EmployeeHrRequest, *, actor: Employee) -> EmployeeHrRequest: + """Сотрудник откликается на открытый запрос → становится подменой и утверждает.""" + locked = EmployeeHrRequest.objects.select_for_update().get(pk=req.pk) + if locked.request_type != 'substitution': + raise ValidationError('Это не заявка на подмену.') + if locked.status != 'pending': + raise ValidationError('Заявка уже обработана.') + if locked.employee_id == actor.pk: + raise ValidationError('Нельзя подтвердить свою заявку на подмену.') + if locked.substitute_id and locked.substitute_id != actor.pk: + raise ValidationError('На эту подмену уже откликнулся другой сотрудник.') + if not actor.is_active or getattr(actor, 'user_location_type', None) != 'pharmacy': + raise ValidationError('Подмену может подтвердить сотрудник аптеки.') + + locked.substitute = actor + locked.status = 'approved' + locked.decided_by = actor + locked.decided_at = timezone.now() + locked.decision_note = 'Подтверждено сотрудником' + locked.save(update_fields=[ + 'substitute', 'status', 'decided_by', 'decided_at', 'decision_note', 'updated_at', + ]) + return locked + + +@transaction.atomic +def decline_substitution( + req: EmployeeHrRequest, *, actor: Employee, decision_note: str = '', +) -> EmployeeHrRequest: + """Отклонение открытого запроса не используется — сотрудник просто не откликается. + + Оставлено для совместимости URL: если вызвали — ошибка с пояснением. + """ + raise ValidationError( + 'Отклонять открытый запрос не нужно: подмену берёт тот, кто нажал «Подтвердить».' + ) + + +@transaction.atomic +def cancel_hr_request(req: EmployeeHrRequest, *, actor: Employee) -> EmployeeHrRequest: + locked = EmployeeHrRequest.objects.select_for_update().get(pk=req.pk) + if locked.employee_id != actor.pk: + raise ValidationError('Можно отменить только свою заявку.') + if locked.status != 'pending': + raise ValidationError('Отменить можно только заявку на рассмотрении.') + locked.status = 'cancelled' + locked.decided_at = timezone.now() + locked.save(update_fields=['status', 'decided_at', 'updated_at']) + return locked + + +def pending_hr_requests_count() -> int: + return EmployeeHrRequest.objects.filter(status='pending').count() diff --git a/apps/employees/services/password_invite.py b/apps/employees/services/password_invite.py new file mode 100644 index 0000000..2fb796b --- /dev/null +++ b/apps/employees/services/password_invite.py @@ -0,0 +1,124 @@ +"""Приглашения для первичной установки / смены пароля портала.""" +from __future__ import annotations + +import secrets +from datetime import timedelta + +from django.conf import settings +from django.db import transaction +from django.utils import timezone + +from apps.employees.models import Employee, PasswordSetupInvite + +DEFAULT_TTL_DAYS = 7 + + +def can_offer_ad_password_sync(employee: Employee) -> bool: + """Офисный сотрудник с привязкой к AD — можно предложить смену пароля в домене.""" + return ( + employee.user_location_type == 'office' + and bool(employee.ad_user_dn) + ) + + +def ad_sync_hint(employee: Employee) -> str | None: + if employee.user_location_type != 'office': + return None + if employee.ad_user_dn: + return None + if employee.ad_sync_enabled: + return ( + 'Обліковий запис AD буде оновлено після синхронізації. ' + 'Якщо обліковки ще немає — зверніться до IT.' + ) + return ( + 'Обліковий запис Active Directory ще не створено. ' + 'Пароль порталу буде встановлено; доменний — після налаштування IT.' + ) + + +def create_password_invite( + employee: Employee, + *, + created_by=None, + ttl_days: int = DEFAULT_TTL_DAYS, + invalidate_previous: bool = True, +) -> PasswordSetupInvite: + if invalidate_previous: + PasswordSetupInvite.objects.filter( + employee=employee, used_at__isnull=True, + ).update(used_at=timezone.now()) + + return PasswordSetupInvite.objects.create( + employee=employee, + token=secrets.token_urlsafe(32), + expires_at=timezone.now() + timedelta(days=ttl_days), + created_by=created_by, + ) + + +def get_active_invite(token: str) -> PasswordSetupInvite | None: + if not token: + return None + try: + invite = PasswordSetupInvite.objects.select_related('employee').get(token=token) + except PasswordSetupInvite.DoesNotExist: + return None + if not invite.is_active: + return None + if not invite.employee.is_active: + return None + return invite + + +def build_invite_absolute_url(request, invite: PasswordSetupInvite) -> str: + path = f'/set-password/{invite.token}/' + if request is not None: + return request.build_absolute_uri(path) + site = getattr(settings, 'PORTAL_PUBLIC_URL', '').rstrip('/') + if site: + return f'{site}{path}' + return path + + +def get_active_invite_for_employee(employee: Employee) -> PasswordSetupInvite | None: + return ( + PasswordSetupInvite.objects.filter( + employee=employee, used_at__isnull=True, expires_at__gt=timezone.now(), + ) + .order_by('-created_at') + .first() + ) + + +@transaction.atomic +def complete_password_setup( + invite: PasswordSetupInvite, + password: str, + *, + sync_ad: bool = False, +) -> Employee: + employee = Employee.objects.select_for_update().get(pk=invite.employee_id) + locked_invite = PasswordSetupInvite.objects.select_for_update().get(pk=invite.pk) + + if not locked_invite.is_active: + raise ValueError('invite_expired') + + employee.set_password(password) + + if sync_ad and can_offer_ad_password_sync(employee): + employee.ad_sync_enabled = True + + employee.save() + + locked_invite.used_at = timezone.now() + locked_invite.save(update_fields=['used_at', 'updated_at']) + + from core.security import log_security_event + log_security_event( + 'employee.password_set_via_invite', + actor=None, + target=str(employee.pk), + details=f'username={employee.username} ad_sync={bool(sync_ad and can_offer_ad_password_sync(employee))}', + ) + return employee diff --git a/apps/employees/services/twofa.py b/apps/employees/services/twofa.py index 821333b..769aac8 100644 --- a/apps/employees/services/twofa.py +++ b/apps/employees/services/twofa.py @@ -5,21 +5,47 @@ одноразовые, использованный код удаляется. """ import base64 +import binascii import hashlib import io +import logging +import re import secrets import pyotp import qrcode +logger = logging.getLogger(__name__) + ISSUER = 'АКС Портал' BACKUP_CODES_COUNT = 10 +_BASE32_RE = re.compile(r'^[A-Z2-7]+=*$') def generate_secret() -> str: return pyotp.random_base32() +def _normalize_secret(secret: str) -> str: + return (secret or '').strip().replace(' ', '').upper() + + +def is_valid_totp_secret(secret: str) -> bool: + """Секрет должен быть base32. Fernet-blob (ошибка расшифровки) — невалиден.""" + normalized = _normalize_secret(secret) + if not normalized: + return False + if normalized.startswith('GAAAA'): + return False + if not _BASE32_RE.fullmatch(normalized): + return False + try: + pyotp.TOTP(normalized) + return True + except (TypeError, ValueError, binascii.Error): + return False + + def provisioning_uri(employee, secret: str) -> str: """otpauth://-URI для добавления в приложение-аутентификатор.""" label = employee.email or employee.username @@ -36,10 +62,29 @@ def qr_data_uri(uri: str) -> str: def verify_totp(secret: str, code: str) -> bool: - if not secret or not code: + if not is_valid_totp_secret(secret) or not code: return False - # valid_window=1 — допускаем соседний 30-сек интервал (рассинхрон часов). - return pyotp.TOTP(secret).verify(code.strip().replace(' ', ''), valid_window=1) + try: + return pyotp.TOTP(_normalize_secret(secret)).verify( + code.strip().replace(' ', ''), valid_window=1, + ) + except (TypeError, ValueError, binascii.Error): + logger.warning('TOTP verify failed: invalid secret or code format') + return False + + +def reset_invalid_2fa(employee) -> bool: + """Сброс 2FA при повреждённом секрете (часто — смена FIELD_ENCRYPTION_KEY).""" + if not employee.totp_enabled: + return False + if is_valid_totp_secret(employee.totp_secret): + return False + logger.warning( + '2FA reset for %s: totp_secret is missing or not valid base32', + employee.username, + ) + disable(employee) + return True def _hash_code(code: str) -> str: @@ -50,10 +95,10 @@ def generate_backup_codes(n: int = BACKUP_CODES_COUNT): """Возвращает (plain_codes, hashes). plain показываем один раз, храним hashes.""" codes, hashes = [], [] for _ in range(n): - raw = secrets.token_hex(4) # 8 hex-символов - code = f'{raw[:4]}-{raw[4:]}' # вид xxxx-xxxx + raw = secrets.token_hex(8) # 64-bit entropy (16 hex) + code = f'{raw[:4]}-{raw[4:8]}-{raw[8:12]}-{raw[12:16]}' codes.append(code) - hashes.append(_hash_code(code)) + hashes.append(_hash_code(code.lower())) return codes, hashes @@ -61,7 +106,8 @@ def verify_and_consume_backup(employee, code: str) -> bool: """Проверяет резервный код; при совпадении удаляет его (одноразовый).""" if not code: return False - h = _hash_code(code.strip().lower().replace(' ', '')) + normalized = code.strip().lower().replace(' ', '') + h = _hash_code(normalized) stored = list(employee.totp_backup_codes or []) if h in stored: stored.remove(h) diff --git a/apps/employees/tests.py b/apps/employees/tests.py index d2279cb..4eb768e 100644 --- a/apps/employees/tests.py +++ b/apps/employees/tests.py @@ -6,6 +6,7 @@ from django.test import TestCase from django.utils import timezone from apps.employees.models import Employee, EmployeePermissionOverride +from core.test_utils import login_2fa def make_employee(username, **kw): @@ -17,18 +18,6 @@ def make_employee(username, **kw): return Employee.objects.create_user(username=username, **kw) -def login_2fa(client, user): - """force_login + прохождение 2FA-гейта (для staff), чтобы middleware не - редиректил на /2fa/. Для проверки самого гейта используйте force_login.""" - 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() - - class EmployeeCleanTests(TestCase): """Валидация clean(): разделение фармацевтов и офисных/складских, даты, руководитель.""" @@ -170,6 +159,14 @@ class TwoFactorTests(TestCase): r = self.client.get('/dashboard/') self.assertNotIn('/2fa/', r.get('Location', '') or '') + def test_totp_enabled_regular_user_must_verify(self): + self.regular.totp_enabled = True + self.regular.save(update_fields=['totp_enabled']) + self.client.force_login(self.regular) + r = self.client.get('/dashboard/') + self.assertEqual(r.status_code, 302) + self.assertIn('/2fa/verify/', r['Location']) + def test_setup_enables_2fa_and_issues_backup_codes(self): import pyotp self.client.force_login(self.staff) @@ -192,7 +189,26 @@ class TwoFactorTests(TestCase): self.staff.refresh_from_db() self.assertFalse(self.staff.totp_enabled) - def test_verify_flow_with_totp(self): + def test_verify_corrupted_secret_redirects_to_setup(self): + self.staff.totp_secret = 'gAAAAABinvalid-fernet-blob-not-base32' + self.staff.totp_enabled = True + self.staff.save() + self.client.force_login(self.staff) + r = self.client.get('/2fa/verify/') + self.assertEqual(r.status_code, 302) + self.assertIn('/2fa/setup/', r['Location']) + self.assertIn('reset=1', r['Location']) + self.staff.refresh_from_db() + self.assertFalse(self.staff.totp_enabled) + + def test_verify_invalid_secret_post_does_not_500(self): + self.staff.totp_secret = 'not-valid-base32!!!' + self.staff.totp_enabled = True + self.staff.save() + self.client.force_login(self.staff) + r = self.client.post('/2fa/verify/', {'code': '123456', 'next': '/'}) + self.assertIn(r.status_code, (200, 302)) + self.assertNotEqual(r.status_code, 500) import pyotp secret = pyotp.random_base32() self.staff.totp_secret = secret @@ -213,6 +229,8 @@ class TwoFactorTests(TestCase): import pyotp from apps.employees.services import twofa codes, hashes = twofa.generate_backup_codes() + self.assertEqual(len(codes), 10) + self.assertRegex(codes[0], r'^[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}$') self.staff.totp_secret = pyotp.random_base32() self.staff.totp_enabled = True self.staff.totp_backup_codes = hashes @@ -239,8 +257,73 @@ class TwoFactorTests(TestCase): self.assertEqual(self.staff.totp_secret, '') self.assertEqual(self.staff.totp_backup_codes, []) + def test_settings_redirects_to_profile_settings(self): + self.client.force_login(self.regular) + r = self.client.get('/settings/') + self.assertEqual(r.status_code, 302) + self.assertIn(f'/employees/{self.regular.pk}/?tab=settings', r['Location']) + + def test_profile_settings_shows_2fa_block(self): + self.client.force_login(self.regular) + r = self.client.get(f'/employees/{self.regular.pk}/?tab=settings') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'Двухфакторная аутентификация') + self.assertContains(r, 'Настроить 2FA') + + def test_setup_via_profile_return_to(self): + import pyotp + self.client.force_login(self.regular) + r = self.client.get('/2fa/setup/?return_to=profile') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'Включить 2FA') + self.assertContains(r, 'data:image/png;base64') + secret = self.client.session['2fa_setup_secret'] + code = pyotp.TOTP(secret).now() + r = self.client.post('/2fa/setup/', {'code': code, 'return_to': 'profile'}) + self.assertEqual(r.status_code, 200) + self.assertContains(r, '2FA успешно включена') + self.regular.refresh_from_db() + self.assertTrue(self.regular.totp_enabled) + + def test_disable_optional_2fa_via_profile(self): + import pyotp + secret = pyotp.random_base32() + self.regular.totp_secret = secret + self.regular.totp_enabled = True + self.regular.totp_backup_codes = ['abc'] + self.regular.save() + self.client.force_login(self.regular) + self.client.session['2fa_verified'] = True + self.client.session.save() + code = pyotp.TOTP(secret).now() + r = self.client.post('/2fa/disable/', {'code': code}) + self.assertEqual(r.status_code, 200) + self.assertContains(r, '2FA отключена') + self.regular.refresh_from_db() + self.assertFalse(self.regular.totp_enabled) + + def test_staff_cannot_disable_2fa(self): + import pyotp + secret = pyotp.random_base32() + self.staff.totp_secret = secret + self.staff.totp_enabled = True + self.staff.save() + self.client.force_login(self.staff) + self.client.session['2fa_verified'] = True + self.client.session.save() + code = pyotp.TOTP(secret).now() + r = self.client.post('/2fa/disable/', {'code': code}) + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'Отключение 2FA недоступно') + self.staff.refresh_from_db() + self.assertTrue(self.staff.totp_enabled) + + def test_staff_can_access_profile_settings_before_2fa(self): + self.client.force_login(self.staff) + r = self.client.get(f'/employees/{self.staff.pk}/?tab=settings') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'Настроить 2FA') -class TwoFAApiEnforcementTests(TestCase): """Staff без 2FA не должен дотягиваться до /api/ (2FA больше не в ALLOWED_PREFIXES).""" def test_api_path_enforced_for_staff_without_2fa(self): diff --git a/apps/employees/tests_hr_requests.py b/apps/employees/tests_hr_requests.py new file mode 100644 index 0000000..15080c4 --- /dev/null +++ b/apps/employees/tests_hr_requests.py @@ -0,0 +1,292 @@ +import datetime + +from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError +from django.test import Client, TestCase +from django.urls import reverse +from django.utils import timezone + +from apps.employees.models import Employee, EmployeeHrRequest +from apps.employees.services.hr_requests import ( + build_leave_balance, + cancel_hr_request, + check_leave_balance, + create_hr_request, + decide_hr_request, + next_pending_vacation, + pending_hr_requests_count, +) +from core.test_utils import login_2fa + + +def make_employee(username, **kw): + kw.setdefault('first_name', 'Иван') + kw.setdefault('last_name', 'Иванов') + kw.setdefault('hire_date', datetime.date(2020, 1, 1)) + kw.setdefault('mattermost_sync_enabled', False) + kw.setdefault('ad_sync_enabled', False) + return Employee.objects.create_user(username=username, **kw) + + +class HrRequestServiceTests(TestCase): + def test_create_vacation_requires_dates(self): + employee = make_employee('emp1') + with self.assertRaises(ValidationError): + create_hr_request( + employee=employee, + request_type='vacation', + date_from=None, + date_to=None, + ) + + def test_create_certificate_without_dates(self): + employee = make_employee('emp2') + req = create_hr_request( + employee=employee, + request_type='certificate', + date_from=None, + date_to=None, + note='2-НДФЛ', + ) + self.assertEqual(req.status, 'pending') + self.assertEqual(req.day_count, 0) + + def test_cancel_own_pending(self): + employee = make_employee('emp3') + req = create_hr_request( + employee=employee, + request_type='day_off', + date_from=datetime.date(2026, 6, 1), + date_to=datetime.date(2026, 6, 1), + ) + cancelled = cancel_hr_request(req, actor=employee) + self.assertEqual(cancelled.status, 'cancelled') + + def test_cannot_cancel_other_user_request(self): + employee = make_employee('emp4') + other = make_employee('emp5') + req = create_hr_request( + employee=employee, + request_type='day_off', + date_from=datetime.date(2026, 6, 2), + date_to=datetime.date(2026, 6, 2), + ) + with self.assertRaises(ValidationError): + cancel_hr_request(req, actor=other) + + def test_decide_approve_and_reject(self): + employee = make_employee('emp6') + hr = make_employee('hr1') + hr.groups.add(Group.objects.get(name='HR')) + req = create_hr_request( + employee=employee, + request_type='vacation', + date_from=datetime.date(2026, 7, 1), + date_to=datetime.date(2026, 7, 5), + ) + approved = decide_hr_request(req, decided_by=hr, approve=True) + self.assertEqual(approved.status, 'approved') + self.assertEqual(approved.decided_by_id, hr.pk) + + req2 = create_hr_request( + employee=employee, + request_type='sick_leave', + date_from=datetime.date(2026, 8, 1), + date_to=datetime.date(2026, 8, 3), + ) + rejected = decide_hr_request(req2, decided_by=hr, approve=False, decision_note='Нет') + self.assertEqual(rejected.status, 'rejected') + self.assertEqual(rejected.decision_note, 'Нет') + + def test_leave_balance_counts_approved_days(self): + employee = make_employee('emp7') + year = timezone.localdate().year + create_hr_request( + employee=employee, + request_type='vacation', + date_from=datetime.date(year, 3, 1), + date_to=datetime.date(year, 3, 5), + ) + hr = make_employee('hr2') + hr.groups.add(Group.objects.get(name='HR')) + pending = EmployeeHrRequest.objects.filter(employee=employee, status='pending').first() + decide_hr_request(pending, decided_by=hr, approve=True) + + balance = build_leave_balance(employee, year=year) + annual = next(row for row in balance if row.key == 'annual') + self.assertEqual(annual.used, 5) + self.assertEqual(annual.remaining, 19) + + def test_next_pending_vacation_future_only(self): + employee = make_employee('emp8') + today = timezone.localdate() + create_hr_request( + employee=employee, + request_type='vacation', + date_from=today - datetime.timedelta(days=5), + date_to=today - datetime.timedelta(days=1), + ) + future = create_hr_request( + employee=employee, + request_type='vacation', + date_from=today + datetime.timedelta(days=10), + date_to=today + datetime.timedelta(days=15), + ) + self.assertEqual(next_pending_vacation(employee).pk, future.pk) + + def test_pending_count(self): + employee = make_employee('emp9') + self.assertEqual(pending_hr_requests_count(), 0) + create_hr_request( + employee=employee, + request_type='certificate', + date_from=None, + date_to=None, + ) + self.assertEqual(pending_hr_requests_count(), 1) + + def test_create_rejects_exceeding_annual_balance(self): + employee = make_employee('emp10') + year = timezone.localdate().year + create_hr_request( + employee=employee, + request_type='vacation', + date_from=datetime.date(year, 1, 1), + date_to=datetime.date(year, 1, 24), + ) + with self.assertRaises(ValidationError): + create_hr_request( + employee=employee, + request_type='vacation', + date_from=datetime.date(year, 2, 1), + date_to=datetime.date(year, 2, 5), + ) + + def test_check_balance_allows_within_limit(self): + employee = make_employee('emp11') + year = timezone.localdate().year + check_leave_balance( + employee, 'vacation', + datetime.date(year, 6, 1), datetime.date(year, 6, 5), + ) + + def test_approve_rejects_when_balance_exhausted(self): + employee = make_employee('emp12') + hr = make_employee('hr3') + hr.groups.add(Group.objects.get(name='HR')) + year = timezone.localdate().year + from apps.employees.services.hr_requests import get_entitlement + + ent = get_entitlement(employee, year) + ent.annual_days = 5 + ent.save(update_fields=['annual_days']) + + req = create_hr_request( + employee=employee, + request_type='vacation', + date_from=datetime.date(year, 1, 1), + date_to=datetime.date(year, 1, 5), + ) + EmployeeHrRequest.objects.create( + employee=employee, + request_type='vacation', + date_from=datetime.date(year, 6, 1), + date_to=datetime.date(year, 6, 3), + status='approved', + ) + with self.assertRaises(ValidationError): + decide_hr_request(req, decided_by=hr, approve=True) + + +class HrRequestViewTests(TestCase): + def setUp(self): + self.client = Client() + self.employee = make_employee('view_emp') + self.hr = make_employee('view_hr') + self.hr.groups.add(Group.objects.get(name='HR')) + + def test_create_request_via_post(self): + login_2fa(self.client, self.employee) + year = timezone.localdate().year + resp = self.client.post( + reverse('hr_request_create'), + { + 'request_type': 'vacation', + 'date_from': f'{year}-09-01', + 'date_to': f'{year}-09-05', + 'note': 'Отпуск', + }, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 200) + self.assertTrue( + EmployeeHrRequest.objects.filter( + employee=self.employee, request_type='vacation', status='pending', + ).exists() + ) + + def test_cancel_own_request(self): + req = create_hr_request( + employee=self.employee, + request_type='day_off', + date_from=datetime.date(2026, 10, 1), + date_to=datetime.date(2026, 10, 1), + ) + login_2fa(self.client, self.employee) + resp = self.client.post(reverse('hr_request_cancel', args=[req.pk]), HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + req.refresh_from_db() + self.assertEqual(req.status, 'cancelled') + + def test_hr_approve_pending(self): + req = create_hr_request( + employee=self.employee, + request_type='vacation', + date_from=datetime.date(2026, 11, 1), + date_to=datetime.date(2026, 11, 3), + ) + login_2fa(self.client, self.hr) + resp = self.client.post(reverse('hr_request_approve', args=[req.pk]), HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + req.refresh_from_db() + self.assertEqual(req.status, 'approved') + + def test_create_certificate_via_post_on_profile(self): + login_2fa(self.client, self.employee) + resp = self.client.post( + reverse('hr_request_create'), + { + 'request_type': 'certificate', + 'note': '2-НДФЛ', + 'return_to': 'profile', + }, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'Заявку подано') + self.assertContains(resp, 'emp-profile-hr-requests') + self.assertContains(resp, 'data-profile-active-tab="overview"') + + def test_validation_error_shown_on_page(self): + login_2fa(self.client, self.employee) + resp = self.client.post( + reverse('hr_request_create'), + {'request_type': 'vacation'}, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'Не удалось подать заявку') + self.assertContains(resp, 'data-hr-request-form-open="1"') + + def test_non_hr_cannot_approve(self): + req = create_hr_request( + employee=self.employee, + request_type='vacation', + date_from=datetime.date(2026, 12, 1), + date_to=datetime.date(2026, 12, 2), + ) + login_2fa(self.client, self.employee) + resp = self.client.post(reverse('hr_request_approve', args=[req.pk]), HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 403) + req.refresh_from_db() + self.assertEqual(req.status, 'pending') diff --git a/apps/employees/tests_password_invite.py b/apps/employees/tests_password_invite.py new file mode 100644 index 0000000..9987be4 --- /dev/null +++ b/apps/employees/tests_password_invite.py @@ -0,0 +1,170 @@ +from datetime import date +from unittest import mock + +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.urls import reverse + +from apps.employees.models import PasswordSetupInvite +from apps.employees.services.password_invite import ( + can_offer_ad_password_sync, + complete_password_setup, + create_password_invite, + get_active_invite, +) +from apps.hiring.models import InternAccount +from apps.hiring.services import approve_hiring_request, create_hiring_request +from core.test_utils import login_2fa + +Employee = get_user_model() + + +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 PasswordInviteUnitTests(TestCase): + def setUp(self): + self.employee = Employee.objects.create( + username='invite.test', + email='invite.test@aks-apteka.com.ua', + first_name='Тест', + last_name='Запрошення', + hire_date=date(2026, 1, 1), + user_location_type='office', + mattermost_sync_enabled=False, + ad_sync_enabled=False, + ) + self.employee.set_unusable_password() + self.employee.save() + + def test_create_and_resolve_invite(self): + invite = create_password_invite(self.employee) + resolved = get_active_invite(invite.token) + self.assertEqual(resolved.pk, invite.pk) + + def test_complete_sets_password(self): + invite = create_password_invite(self.employee) + complete_password_setup(invite, 'GoodPass123!') + self.employee.refresh_from_db() + self.assertTrue(self.employee.check_password('GoodPass123!')) + invite.refresh_from_db() + self.assertIsNotNone(invite.used_at) + + def test_ad_checkbox_only_with_dn(self): + self.assertFalse(can_offer_ad_password_sync(self.employee)) + self.employee.ad_user_dn = 'CN=Test,OU=Users,DC=local' + self.assertTrue(can_offer_ad_password_sync(self.employee)) + + @mock.patch('apps.ad_connector.services.sync_service.sync_ad_password', return_value=True) + def test_sync_ad_on_setup(self, sync_pw): + self.employee.ad_user_dn = 'CN=Test,OU=Users,DC=local' + self.employee.save() + invite = create_password_invite(self.employee) + complete_password_setup(invite, 'GoodPass123!', sync_ad=True) + self.employee.refresh_from_db() + self.assertTrue(self.employee.ad_sync_enabled) + sync_pw.assert_called_once() + + +class PasswordSetupViewTests(TestCase): + def setUp(self): + self.employee = Employee.objects.create( + username='portal.setup', + email='portal.setup@local.domain', + first_name='Портал', + last_name='Setup', + hire_date=date(2026, 1, 1), + user_location_type='pharmacy', + mattermost_sync_enabled=False, + ad_sync_enabled=False, + ) + self.employee.set_unusable_password() + self.employee.save() + self.invite = create_password_invite(self.employee) + self.client = Client() + + def test_setup_form_and_login_redirect(self): + url = reverse('password_setup', kwargs={'token': self.invite.token}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + response = self.client.post(url, { + 'password': 'SecurePass123', + 'password_confirm': 'SecurePass123', + }) + self.assertEqual(response.status_code, 302) + self.assertIn('/login/', response['Location']) + self.employee.refresh_from_db() + self.assertTrue(self.employee.check_password('SecurePass123')) + + +class HiringInviteFlowTests(TestCase): + @classmethod + def setUpTestData(cls): + from django.contrib.auth.models import Group + from core.models import Directory, DirectoryType + from apps.locations.models import Pharmacy + + org_type, _ = DirectoryType.objects.get_or_create(code='organization', defaults={'name': 'Организация'}) + region_type, _ = DirectoryType.objects.get_or_create(code='region', defaults={'name': 'Регион'}) + dept_type, _ = DirectoryType.objects.get_or_create(code='department', defaults={'name': 'Відділ'}) + post_type, _ = DirectoryType.objects.get_or_create(code='post', defaults={'name': 'Post'}) + 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.dept = Directory.objects.create(directory_type=dept_type, code='hr', name='HR') + cls.post = Directory.objects.create(directory_type=post_type, name='Clerk') + cls.pharmacy = Pharmacy.objects.create( + organization=cls.org, location_region=cls.region, + pharmacy_number=1, location_type='pharmacy', + ) + cls.hr = Employee.objects.create( + username='hr.invite.flow', + email='hr.invite.flow@company.local', + first_name='HR', + last_name='Flow', + hire_date=date(2026, 1, 1), + user_location_type='office', + mattermost_sync_enabled=False, + ad_sync_enabled=False, + ) + cls.hr.groups.add(Group.objects.get(name='HR')) + + def setUp(self): + InternAccount.objects.all().delete() + make_intern('Intern-1') + + def _create_trial(self): + return create_hiring_request( + requester=self.hr, + last_name='Новий', + first_name='Співробітник', + user_location_type='pharmacy', + user_pharmacy_id=self.pharmacy.pk, + department_id=self.dept.pk, + post_id=self.post.pk, + hire_date=date(2026, 8, 1), + trial_days=3, + ) + + def test_approve_creates_invite_not_password(self): + hr = self._create_trial() + approved = approve_hiring_request(hr, decided_by=self.hr) + self.assertEqual(approved.issued_password, '') + self.assertFalse(approved.employee.has_usable_password()) + self.assertTrue( + PasswordSetupInvite.objects.filter(employee=approved.employee, used_at__isnull=True).exists() + ) + + def test_credentials_pdf_repeatable(self): + hr = self._create_trial() + approve_hiring_request(hr, decided_by=self.hr) + client = Client() + login_2fa(client, self.hr) + url = reverse('hiring_credentials_pdf', kwargs={'pk': hr.pk}) + self.assertEqual(client.get(url).status_code, 200) + self.assertEqual(client.get(url).status_code, 200) diff --git a/apps/employees/views_password.py b/apps/employees/views_password.py new file mode 100644 index 0000000..8d75e5e --- /dev/null +++ b/apps/employees/views_password.py @@ -0,0 +1,69 @@ +"""Публичная установка пароля по приглашению.""" +from django.contrib.auth import password_validation +from django.core.exceptions import ValidationError +from django.shortcuts import redirect, render +from django.views.decorators.http import require_http_methods + +from apps.employees.services.password_invite import ( + ad_sync_hint, + can_offer_ad_password_sync, + complete_password_setup, + get_active_invite, +) + + +def _validate_password_pair(password: str, confirm: str) -> list[str]: + errors = [] + if not password: + errors.append('Введіть пароль.') + if password != confirm: + errors.append('Паролі не збігаються.') + if password: + try: + password_validation.validate_password(password) + except ValidationError as exc: + errors.extend(exc.messages) + return errors + + +@require_http_methods(['GET', 'POST']) +def password_setup_view(request, token): + invite = get_active_invite(token) + if invite is None: + return render(request, 'password-setup.html', { + 'invalid': True, + }, status=404) + + employee = invite.employee + show_ad = can_offer_ad_password_sync(employee) + hint = ad_sync_hint(employee) + + if request.method == 'POST': + password = request.POST.get('password', '') + confirm = request.POST.get('password_confirm', '') + sync_ad = show_ad and request.POST.get('sync_ad') == '1' + errors = _validate_password_pair(password, confirm) + + if not errors: + try: + complete_password_setup(invite, password, sync_ad=sync_ad) + except ValueError: + return render(request, 'password-setup.html', { + 'invalid': True, + }, status=410) + return redirect(f'/login/?setup=1&user={employee.username}') + + return render(request, 'password-setup.html', { + 'employee': employee, + 'show_ad_sync': show_ad, + 'ad_sync_hint': hint, + 'errors': errors, + 'sync_ad_checked': sync_ad, + }) + + return render(request, 'password-setup.html', { + 'employee': employee, + 'show_ad_sync': show_ad, + 'ad_sync_hint': hint, + 'expires_at': invite.expires_at, + }) diff --git a/static/assets/js/employee-profile.js b/static/assets/js/employee-profile.js new file mode 100644 index 0000000..3c3998a --- /dev/null +++ b/static/assets/js/employee-profile.js @@ -0,0 +1,79 @@ +function empProfileRoot() { + return document.querySelector('#main-content .page-content[data-profile-active-tab]'); +} + +function empProfileTabSwitch(tab) { + var root = empProfileRoot(); + if (!root) return; + root.querySelectorAll('.emp-profile-panel').forEach(function (panel) { + panel.style.display = panel.id === 'emp-profile-panel-' + tab ? 'block' : 'none'; + }); + root.querySelectorAll('.emp-profile-tab-btn').forEach(function (btn) { + var active = btn.getAttribute('data-tab') === tab; + btn.classList.toggle('emp-profile-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)' : ''; + }); + if (typeof history !== 'undefined' && history.replaceState) { + var url = new URL(window.location.href); + url.searchParams.set('tab', tab); + history.replaceState(null, '', url.pathname + url.search); + } +} + +function empProfileInit() { + var root = empProfileRoot(); + if (!root) return; + var tab = root.getAttribute('data-profile-active-tab') || 'overview'; + empProfileTabSwitch(tab); + var reqType = document.getElementById('emp-hr-request-type'); + if (reqType) { + empProfileRequestTypeToggle(reqType.value); + if (!document.getElementById('emp-hr-request-type-label').textContent.trim()) { + var btn = document.querySelector('.emp-hr-type-btn[data-request-type="' + reqType.value + '"]'); + if (btn) document.getElementById('emp-hr-request-type-label').textContent = btn.getAttribute('data-request-label') || btn.textContent.trim(); + } + } +} + +function empProfileShowRequestForm(type, label) { + var wrap = document.getElementById('emp-hr-request-form-wrap'); + var input = document.getElementById('emp-hr-request-type'); + var labelEl = document.getElementById('emp-hr-request-type-label'); + if (wrap) wrap.style.display = 'block'; + if (input && type) { + input.value = type; + empProfileRequestTypeToggle(type); + } + if (labelEl && label) labelEl.textContent = label; +} + +function empProfileHideRequestForm() { + var wrap = document.getElementById('emp-hr-request-form-wrap'); + if (wrap) wrap.style.display = 'none'; +} + +function empProfileRequestTypeToggle(type) { + var needsDates = ['vacation', 'additional_vacation', 'sick_leave', 'day_off'].indexOf(type) !== -1; + var datesWrap = document.getElementById('emp-hr-request-dates-wrap'); + var dateToWrap = document.getElementById('emp-hr-request-date-to-wrap'); + var dateFrom = document.getElementById('emp-hr-request-date-from'); + var dateTo = document.getElementById('emp-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; +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', empProfileInit); +} else { + empProfileInit(); +} +document.addEventListener('htmx:afterSwap', function (ev) { + var target = ev.detail && ev.detail.target; + if (target && target.id === 'main-content') { + empProfileInit(); + } +}); diff --git a/templates/2fa-backup-codes.html b/templates/2fa-backup-codes.html index 5d3915e..75e420b 100644 --- a/templates/2fa-backup-codes.html +++ b/templates/2fa-backup-codes.html @@ -6,22 +6,74 @@ Резервные коды — АКС Портал + -
+

2FA включена ✓

-

Сохраните резервные коды в надёжном месте. Каждый работает один раз — используйте их, если потеряете доступ к приложению. Больше они не будут показаны.

+

Сохраните резервные коды в надёжном месте. Каждый работает один раз — используйте их, если потеряете доступ к приложению. Больше они не будут показаны.

-
-
+
+
{% for code in codes %} - {{ code }} + {{ code }} {% endfor %}
- Я сохранил коды — продолжить + Я сохранил коды — продолжить
diff --git a/templates/2fa-setup.html b/templates/2fa-setup.html index 0d2c8f9..de4361f 100644 --- a/templates/2fa-setup.html +++ b/templates/2fa-setup.html @@ -16,6 +16,9 @@
+ {% if reset_notice %} +
{{ reset_notice }}
+ {% endif %} {% if error %}
{{ error }}
{% endif %} diff --git a/templates/pages/employee-detail.html b/templates/pages/employee-detail.html deleted file mode 100644 index 280ba28..0000000 --- a/templates/pages/employee-detail.html +++ /dev/null @@ -1,257 +0,0 @@ -{% load common_tags %} -{% load i18n %} -
- - -
- - - - - До списку співробітників - -
- {% if employee.mattermost_user_id and employee.pk != request.user.pk %} - - - Написати в чат - - {% endif %} - - - Призначити задачу - - {% if can_edit %} - {% trans "Редактировать" %} - {% endif %} -
-
- - -
-
- {% if employee.photo %} - - {% else %} - {{ employee|initials }} - {% endif %} -
-
-

{{ employee.last_name }} {{ employee.first_name }}{% if employee.middle_name %} {{ employee.middle_name }}{% endif %}

- {% if employee.is_active %} - {% trans "Активный" %} - {% else %} - {% trans "Не активный" %} - {% endif %} - {% if can_view_pii and employee.is_on_probation %}{% trans "Испытательный срок" %}{% endif %} -
-
- {% if employee.post %}{{ employee.post.name }}{% else %}{{ employee.groups.first.name|default:"Посада не вказана" }}{% endif %} - {% if employee.department %} · {{ employee.department.name }}{% elif employee.user_pharmacy %} · Аптека №{{ employee.user_pharmacy.pharmacy_number }}{% endif %} -
- {% if employee.user_organization %}
{{ employee.user_organization.name }}
{% endif %} -
-
-
- -
- - -
- - -
-

{% trans "Контакты" %}

-
-
-
{% trans "Рабочий email" %}
-
{{ employee.email|default:"—" }}
-
-
-
{% trans "Рабочий телефон" %}
-
{{ employee.phone|default:"—" }}
-
-
-
{% trans "Внутренний номер" %}
-
{{ employee.internal_number|default:"—" }}
-
- {% if can_view_pii %} -
-
{% trans "Личный телефон" %}
-
{{ employee.personal_phone|default:"—" }}
-
-
-
{% trans "Личный email" %}
-
{{ employee.personal_email|default:"—" }}
-
- {% endif %} -
-
- - -
-

{% trans "Работа" %}

-
-
-
{% trans "Руководитель" %}
-
- {% if employee.manager %} - {{ employee.manager.get_full_name }} - {% else %}—{% endif %} -
-
-
-
{% trans "Подчинённых" %}
-
{{ employee.subordinate_count }}
-
- {% if can_view_pii %} -
-
{% trans "Дата приёма" %}
-
{{ employee.hire_date|date:"d.m.Y" }} ({{ employee.work_experience }} р.)
-
-
-
{% trans "Дата увольнения" %}
-
{{ employee.termination_date|date:"d.m.Y"|default:"—" }}
-
- {% endif %} -
-
- - {% if can_view_pii %} - -
-

{% trans "Личные данные" %}

-
-
-
{% trans "Дата рождения" %}
-
{{ employee.birth_date|date:"d.m.Y"|default:"—" }}{% if employee.age %} ({{ employee.age }} р.){% endif %}
-
-
-
{% trans "ИНН" %}
-
{{ employee.inn_code|default:"—" }}
-
-
- {% if employee.emergency_contact_name %} -
-
{% trans "Экстренный контакт" %}
-
{{ employee.emergency_contact_name }}{% if employee.emergency_contact_relation %} ({{ employee.emergency_contact_relation }}){% endif %}
- {% if employee.emergency_contact_phone %}
{{ employee.emergency_contact_phone }}
{% endif %} -
- {% endif %} -
- - -
-

{% trans "Интеграции" %}

-
-
- Mattermost{% if employee.mattermost_username %} ({{ employee.mattermost_username }}){% endif %} - {% if employee.mattermost_sync_status == 'synced' %}{% trans "Синхронизировано" %} - {% elif employee.mattermost_sync_status == 'error' %}{% trans "Ошибка" %} - {% elif employee.mattermost_sync_status == 'disabled' %}{% trans "Отключено" %} - {% else %}{% trans "Ожидает" %}{% endif %} -
-
- Active Directory - {% if employee.ad_sync_status == 'synced' %}{% trans "Синхронизировано" %} - {% elif employee.ad_sync_status == 'error' %}{% trans "Ошибка" %} - {% elif employee.ad_sync_status == 'disabled' %}{% trans "Отключено" %} - {% else %}{% trans "Не синхронизировано" %}{% endif %} -
-
-
- - -
-
-

Задачі{% if recent_tasks_total %} · {{ recent_tasks_total }}{% endif %}

- + Нова -
- {% for task in recent_tasks %} - -
-
{{ task.title|truncatechars:50 }}
-
- {% if task.status == 'done' %}Виконано - {% elif task.status == 'in_progress' %}В роботі - {% else %}Очікує{% endif %} - {% if task.due_date %} · до {{ task.due_date|date:"d.m.Y" }}{% if task.is_overdue %} (прострочено){% endif %}{% endif %} -
-
- {{ task.get_priority_display }} -
- {% empty %} -
{% trans "Задач пока нет" %}
- {% endfor %} - {% if recent_tasks_hidden %} -
і ще {{ recent_tasks_hidden }}…
- {% endif %} -
- {% endif %} -
- - -
- - {% if manager_chain %} -
-

{% trans "Цепочка руководства" %}

-
- {% for mgr in manager_chain %} - - {{ mgr|initials }} - {{ mgr.get_full_name }} - - {% endfor %} -
-
- {% endif %} - - {% if subordinates %} -
-

Підлеглі · {{ subordinates|length }}

- -
- {% endif %} - - {% if can_view_pii and last_attendance %} -
-

{% trans "Последнее посещение" %}

-
- {% if last_attendance.direction == 'in' %}Прихід{% else %}Вихід{% endif %} · {{ last_attendance.timestamp|date:"d.m.Y H:i" }} -
-
{{ last_attendance.location.name }}
-
- {% endif %} - - {% if employee.groups.all %} -
-

{% trans "Группы доступа" %}

-
- {% for group in employee.groups.all %} - {{ group.name }} - {% endfor %} -
-
- {% endif %} - -
-
-
diff --git a/templates/pages/employee-profile.html b/templates/pages/employee-profile.html new file mode 100644 index 0000000..6de46d1 --- /dev/null +++ b/templates/pages/employee-profile.html @@ -0,0 +1,190 @@ +{% load common_tags %} +{% load i18n %} +
+{% with tab=profile_active_tab|default:'overview' %} + + {% if saved %} +
+ {% trans "Изменения сохранены" %} +
+ {% endif %} + {% if errors %} +
+ {% for err in errors %}
{{ err }}
{% endfor %} +
+ {% 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 is_own_profile %} +
{% trans "Мой профиль" %}
+ {% else %} + + + {% trans "К списку сотрудников" %} + + {% endif %} +
+ +
+ {% include "partials/employee-profile-aside.html" %} + +
+ +
+ + {% if is_own_profile or can_view_pii %} + + {% endif %} + + {% if is_own_profile %} + + {% endif %} +
+ + + + + + {% if is_own_profile or can_view_pii %} + + {% endif %} + + + + + + {% if is_own_profile %} + + {% endif %} + +
+
+ +{% endwith %} +
diff --git a/templates/pages/profile.html b/templates/pages/profile.html deleted file mode 100644 index b548018..0000000 --- a/templates/pages/profile.html +++ /dev/null @@ -1,107 +0,0 @@ -{% load common_tags %} -{% load i18n %} -
- -
-

{% trans "Мой профиль" %}

-

{% trans "Личные контактные данные и настройки интерфейса" %}

-
- - {% if saved %} -
- Зміни збережено -
- {% endif %} - - {% if errors %} -
- {% for err in errors %}
{{ err }}
{% endfor %} -
- {% endif %} - -
- {% csrf_token %} - - -
-
- -
-

{{ employee.last_name }} {{ employee.first_name }}{% if employee.middle_name %} {{ employee.middle_name }}{% endif %}

-
- {% if employee.post %}{{ employee.post.name }}{% endif %}{% if employee.department %} · {{ employee.department.name }}{% elif employee.user_pharmacy %} · Аптека №{{ employee.user_pharmacy.pharmacy_number }}{% endif %} -
-
{{ employee.username }} · {{ employee.email }}
-
-
-

- Службові дані (посада, відділ, робочий email) редагує адміністратор. Клацніть на фото, щоб замінити його. -

-
- - -
-

{% trans "Контактные данные" %}

-
-
- - -
-
- - -
-
- - -
-
-
- - -
-

{% trans "Контакт для экстренных случаев" %}

-
-
- - -
-
- - -
-
- - -
-
-
- - -
-

{% trans "Интерфейс" %}

-
- - -

{% trans "Применяется ко всем таблицам портала, если для конкретной таблицы размер не выбран вручную." %}

-
-
- - -
- -
diff --git a/templates/partials/employee-profile-2fa.html b/templates/partials/employee-profile-2fa.html new file mode 100644 index 0000000..470dc29 --- /dev/null +++ b/templates/partials/employee-profile-2fa.html @@ -0,0 +1,113 @@ +{% load i18n %} +
+

{% trans "Двухфакторная аутентификация (2FA)" %}

+

+ {% if twofa_mandatory %} + {% trans "Для вашего аккаунта 2FA обязательна." %} + {% else %} + {% trans "Дополнительная защита входа через приложение-аутентификатор." %} + {% endif %} +

+ + {% if twofa_message %} +
{{ twofa_message }}
+ {% endif %} + {% if twofa_error %} +
{{ twofa_error }}
+ {% endif %} + +
+ {% trans "Статус" %} + {% if twofa_enabled %} + {% trans "Включена" %} + {% else %} + {% trans "Выключена" %} + {% endif %} + {% if twofa_enabled %} + + {% blocktrans count counter=twofa_backup_remaining %}Осталось {{ counter }} резервный код{% plural %}Осталось {{ counter }} резервных кодов{% endblocktrans %} + + {% endif %} +
+ + {% if twofa_backup_codes %} +
+

{% trans "Резервные коды (сохраните сейчас — больше не будут показаны)" %}

+
+ {% for code in twofa_backup_codes %} + {{ code }} + {% endfor %} +
+
+ {% endif %} + + {% if twofa_show_setup %} +
+ {% if twofa_reset_notice %} +
{{ twofa_reset_notice }}
+ {% endif %} + {% if twofa_setup_error %} +
{{ twofa_setup_error }}
+ {% endif %} + +
+
+
+
{% trans "Ключ вручную:" %}
+ {{ twofa_setup_secret }} +
+
+ {% csrf_token %} + + + +
+ + +
+
+
+
+ QR +
+
+
+ + {% elif not twofa_enabled %} + + + {% else %} +
+
+ {% csrf_token %} +

{% trans "Новые резервные коды (старые перестанут работать)." %}

+ + + +
+ + {% if twofa_can_disable %} +
+ {% csrf_token %} +

{% trans "Отключить двухфакторную аутентификацию." %}

+ + + +
+ {% elif twofa_mandatory %} +

{% trans "Отключение недоступно — 2FA обязательна для вашего аккаунта." %}

+ {% endif %} +
+ {% endif %} +
diff --git a/templates/partials/employee-profile-aside.html b/templates/partials/employee-profile-aside.html new file mode 100644 index 0000000..92b32be --- /dev/null +++ b/templates/partials/employee-profile-aside.html @@ -0,0 +1,90 @@ +{% load common_tags %} +{% load i18n %} + diff --git a/templates/partials/employee-profile-hr-requests.html b/templates/partials/employee-profile-hr-requests.html new file mode 100644 index 0000000..3373959 --- /dev/null +++ b/templates/partials/employee-profile-hr-requests.html @@ -0,0 +1,99 @@ +{% load i18n %} +
+
+ {% trans "Заявки и отпуска" %} +
+ + + + + +
+
+ +
+
+ {% csrf_token %} + + +
+ {% if hr_request_form.type %}{% for value, label in hr_request_types %}{% if hr_request_form.type == value %}{{ label }}{% endif %}{% endfor %}{% else %}{% trans "Новая заявка" %}{% endif %} + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ +
+
+
{% trans "Мои заявки" %}
+ {% for req in my_hr_requests|slice:":8" %} +
+
+
{{ req.summary }}
+
{{ req.created_at|date:"d.m.Y" }}{% if req.day_count %} · {{ req.day_count }} {% trans "дн." %}{% endif %}
+
+
+ {% if req.status == 'pending' %} + {{ req.get_status_display }} +
+ {% csrf_token %} + + +
+ {% elif req.status == 'approved' %} + {{ req.get_status_display }} + {% elif req.status == 'rejected' %} + {{ req.get_status_display }} + {% else %} + {{ req.get_status_display }} + {% endif %} +
+
+ {% empty %} +
{% trans "Заявок пока нет" %}
+ {% endfor %} +
+
+
{% trans "Баланс отпусков" %} — {{ leave_balance_year }}
+
+ {% for row in leave_balance %} +
+
+ {{ row.label }} + {{ row.used }}/{{ row.entitlement }} {% trans "дн." %} +
+
+
{% trans "Остаток" %}: {{ row.remaining }}{% if row.pending %} · {% trans "ожидает" %}: {{ row.pending }}{% endif %}
+
+ {% endfor %} + {% if next_vacation_request %} +
+
{% trans "Следующий отпуск" %}
+
+ {{ next_vacation_request.summary }} + {{ next_vacation_request.get_status_display }} +
+
+ {% endif %} +
+
+
+
diff --git a/templates/partials/employee-profile-overview.html b/templates/partials/employee-profile-overview.html new file mode 100644 index 0000000..98dd8e3 --- /dev/null +++ b/templates/partials/employee-profile-overview.html @@ -0,0 +1,123 @@ +{% load common_tags %} +{% load i18n %} + +{% if is_own_profile %} +{% include "partials/employee-profile-hr-requests.html" %} +{% endif %} + +
+ + {% if employee.manager or manager_chain or subordinates or employee.groups.all %} +
+

{% trans "Структура" %}

+
+ {% if employee.manager %} + + {% endif %} + {% if employee.groups.all %} +
+
{% trans "Группы доступа" %}
+
+ {% for group in employee.groups.all %} + {{ group.name }} + {% endfor %} +
+
+ {% endif %} +
+ {% if subordinates %} +
+
{% trans "Подчинённые" %} · {{ subordinates|length }}
+ +
+ {% elif employee.is_manager %} +
{% trans "Нет подчинённых" %}
+ {% endif %} + {% if manager_chain|length > 1 %} +
+
{% trans "Цепочка руководства" %}
+
+ {% for mgr in manager_chain %} + {% if not forloop.first %}→{% endif %} + {{ mgr.get_full_name }} + {% endfor %} +
+
+ {% endif %} +
+ {% endif %} + + {% if can_view_pii %} +
+ {% if employee.hire_date %} +
+
{% trans "Стаж" %}
+
{% trans "с" %} {{ employee.hire_date|date:"d.m.Y" }} · {{ employee.work_experience }} {% trans "л." %}
+
+ {% endif %} + {% if last_attendance %} +
+
{% trans "Последнее посещение" %}
+
{% if last_attendance.direction == 'in' %}{% trans "Приход" %}{% else %}{% trans "Выход" %}{% endif %} · {{ last_attendance.timestamp|date:"d.m.Y H:i" }} · {{ last_attendance.location.name }}
+
+ {% endif %} +
+ Mattermost + {% if employee.mattermost_sync_status == 'synced' %}OK{% elif employee.mattermost_sync_status == 'error' %}{% trans "Ошибка" %}{% else %}—{% endif %} + AD + {% if employee.ad_sync_status == 'synced' %}OK{% elif employee.ad_sync_status == 'error' %}{% trans "Ошибка" %}{% else %}—{% endif %} +
+ +
+ {% elif not is_own_profile and not can_view_pii %} +
+ {% trans "Контакты доступны руководителю и HR." %} + +
+ {% endif %} +
+ +{% if recent_tasks %} +
+
+

{% trans "Задачи" %} · {{ recent_tasks_total }}

+ {% trans "Все задачи" %} +
+ {% for task in recent_tasks %} + +
+
{{ task.title|truncatechars:80 }}
+
+ {% if task.status == 'done' %}{% trans "Выполнено" %}{% elif task.status == 'in_progress' %}{% trans "В работе" %}{% else %}{% trans "Ожидает" %}{% endif %} + {% if task.due_date %} · {% trans "до" %} {{ task.due_date|date:"d.m.Y" }}{% endif %} +
+
+ {{ task.get_priority_display }} +
+ {% endfor %} + {% if recent_tasks_hidden %} +
{% trans "и ещё" %} {{ recent_tasks_hidden }}…
+ {% endif %} +
+{% endif %} + +{% if is_own_profile %} +

{% trans "Служебные данные (должность, отдел, рабочий email) редактирует администратор." %}

+{% endif %} diff --git a/templates/partials/employee-profile-sidebar.html b/templates/partials/employee-profile-sidebar.html new file mode 100644 index 0000000..f24b85d --- /dev/null +++ b/templates/partials/employee-profile-sidebar.html @@ -0,0 +1,53 @@ +{% load common_tags %} +{% load i18n %} +{% if manager_chain %} +
+

{% trans "Цепочка руководства" %}

+
+ {% for mgr in manager_chain %} + + {{ mgr|initials }} + {{ mgr.get_full_name }} + + {% endfor %} +
+
+{% endif %} + +{% if subordinates %} +
+

{% trans "Подчинённые" %} · {{ subordinates|length }}

+ +
+{% endif %} + +{% if can_view_pii and last_attendance %} +
+

{% trans "Последнее посещение" %}

+
{% if last_attendance.direction == 'in' %}{% trans "Приход" %}{% else %}{% trans "Выход" %}{% endif %} · {{ last_attendance.timestamp|date:"d.m.Y H:i" }}
+
{{ last_attendance.location.name }}
+
+{% endif %} + +{% if employee.groups.all %} +
+

{% trans "Группы доступа" %}

+
+ {% for group in employee.groups.all %} + {{ group.name }} + {% endfor %} +
+
+{% endif %} diff --git a/templates/password-setup.html b/templates/password-setup.html new file mode 100644 index 0000000..2f21d37 --- /dev/null +++ b/templates/password-setup.html @@ -0,0 +1,78 @@ +{% load static %} + + + + + + АКС — Встановлення пароля + + + + +
+ +
+
+ АКС +
+

+ {% if invalid %}Посилання недійсне{% else %}Встановлення пароля{% endif %} +

+ {% if invalid %} +

+ Посилання прострочене, вже використане або не існує. Зверніться до HR або IT для нового запрошення. +

+

← На сторінку входу

+ {% else %} +

+ {{ employee.last_name }} {{ employee.first_name }} · логін {{ employee.username }} +

+ {% if expires_at %} +

+ Дійсне до {{ expires_at|date:"d.m.Y H:i" }} +

+ {% endif %} + {% endif %} +
+ + {% if not invalid %} +
+ {% csrf_token %} + {% if errors %} +
+ {% for err in errors %}
{{ err }}
{% endfor %} +
+ {% endif %} + +
+ + +
+
+ + +
+ + {% if show_ad_sync %} + + {% elif ad_sync_hint %} +

+ {{ ad_sync_hint }} +

+ {% endif %} + + +
+ {% endif %} +
+ +