FIX PAGES AND ADD PASSWORD CHANGE

This commit is contained in:
2026-09-11 14:51:55 +03:00
parent f061c8f95b
commit f46e0d90ba
6 changed files with 158 additions and 16 deletions
+94 -4
View File
@@ -1,15 +1,19 @@
from django import forms
from django.contrib import admin
from django.contrib import admin, messages
from django.contrib.auth import password_validation
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.html import format_html
from django.utils.safestring import mark_safe
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 core.security import filter_portal_permissions_for_user, log_security_event
from .models import Employee, PermissionGroup, EmployeeDefaults, EmployeePermissionOverride
from .models import PasswordSetupInvite
from .services.password_invite import can_offer_ad_password_sync
from .models import EmployeeHrRequest, LeaveEntitlement
from apps.visit_manager.models import WorkSchedule, AttendanceLog
@@ -65,6 +69,25 @@ class EmployeeAdminForm(forms.ModelForm):
label=_('Локация (офис/склад)'),
help_text=_('Заполняется для location_type=office или warehouse'),
)
new_password = forms.CharField(
label=_('Новый пароль'),
required=False,
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}, render_value=False),
help_text=_('Оставьте пустым, чтобы не менять текущий пароль портала.'),
)
new_password_confirm = forms.CharField(
label=_('Подтверждение пароля'),
required=False,
strip=False,
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}, render_value=False),
)
sync_ad_password = forms.BooleanField(
label=_('Также сменить пароль в Active Directory'),
required=False,
initial=False,
help_text=_('Доступно для офисных сотрудников с привязкой к AD.'),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -75,10 +98,46 @@ class EmployeeAdminForm(forms.ModelForm):
.select_related('location_region')
.order_by('location_type', 'address')
)
offer_ad = bool(self.instance and self.instance.pk and can_offer_ad_password_sync(self.instance))
self.fields['sync_ad_password'].disabled = not offer_ad
if not offer_ad:
self.fields['sync_ad_password'].help_text = _(
'Синхронизация с AD недоступна: нужен офисный сотрудник с заполненным DN.'
)
def clean(self):
cleaned = super().clean()
password = (cleaned.get('new_password') or '')
confirm = (cleaned.get('new_password_confirm') or '')
if password or confirm:
if password != confirm:
self.add_error('new_password_confirm', _('Пароли не совпадают.'))
elif password:
try:
password_validation.validate_password(password, self.instance)
except DjangoValidationError as exc:
self.add_error('new_password', exc)
if cleaned.get('sync_ad_password') and password:
if not (self.instance and self.instance.pk and can_offer_ad_password_sync(self.instance)):
self.add_error(
'sync_ad_password',
_('Смена пароля в AD недоступна для этого сотрудника.'),
)
return cleaned
def save(self, commit=True):
obj = super().save(commit=False)
password = self.cleaned_data.get('new_password') or ''
if password:
obj.set_password(password)
if commit:
obj.save()
self.save_m2m()
return obj
class Meta:
model = Employee
fields = '__all__'
exclude = ('password',)
class WorkScheduleInline(TabularInline):
model = WorkSchedule
@@ -171,7 +230,10 @@ class EmployeeAdmin(ModelAdmin):
fieldsets = (
(_('Аутентификация'), {
'fields': ('username', 'email', 'groups', 'is_active'),
'fields': (
'username', 'email', 'groups', 'is_active',
'new_password', 'new_password_confirm', 'sync_ad_password',
),
'classes': ('tab',),
}),
(_('ФИО'), {
@@ -246,6 +308,34 @@ class EmployeeAdmin(ModelAdmin):
if previous:
obj.groups.set(previous.groups.all())
super().save_model(request, obj, form, change)
password = (form.cleaned_data or {}).get('new_password') or ''
if not password:
return
from django.utils import timezone
PasswordSetupInvite.objects.filter(
employee=obj, used_at__isnull=True,
).update(used_at=timezone.now())
log_security_event(
'employee.password_set_via_admin',
actor=request.user,
target=str(obj.pk),
details=f'username={obj.username}',
)
self.message_user(request, _('Пароль портала обновлён.'), messages.SUCCESS)
if form.cleaned_data.get('sync_ad_password') and can_offer_ad_password_sync(obj):
from apps.ad_connector.services.sync_service import sync_ad_password
try:
ok = sync_ad_password(obj, password)
except Exception:
ok = False
if ok:
self.message_user(request, _('Пароль в Active Directory обновлён.'), messages.SUCCESS)
else:
self.message_user(
request,
_('Пароль портала сохранён, но сменить пароль в AD не удалось.'),
messages.WARNING,
)
# def permissions_summary(self, obj):
# perms = obj.effective_permissions()
+55
View File
@@ -332,3 +332,58 @@ class TwoFactorTests(TestCase):
r = self.client.get('/api/attendance/')
self.assertEqual(r.status_code, 302)
self.assertIn('/2fa/', r['Location'])
class EmployeeAdminPasswordTests(TestCase):
"""Админ может задать пароль сотрудника из карточки Unfold."""
def setUp(self):
self.employee = make_employee('pwd.target', password='OldPass123')
def _form(self, **overrides):
from apps.employees.admin import EmployeeAdminForm
emp = self.employee
data = {
'username': emp.username,
'email': emp.email or '',
'last_name': emp.last_name,
'first_name': emp.first_name,
'hire_date': emp.hire_date.isoformat(),
'date_joined': emp.date_joined.strftime('%Y-%m-%d %H:%M:%S'),
'is_active': True,
'mattermost_sync_status': emp.mattermost_sync_status,
'ad_sync_status': emp.ad_sync_status,
'default_page_size': emp.default_page_size,
}
data.update(overrides)
return EmployeeAdminForm(data, instance=emp)
def test_blank_password_keeps_current(self):
form = self._form(new_password='', new_password_confirm='')
self.assertTrue(form.is_valid(), form.errors)
form.save()
self.employee.refresh_from_db()
self.assertTrue(self.employee.check_password('OldPass123'))
def test_mismatch_rejected(self):
form = self._form(new_password='GoodPass123!', new_password_confirm='other')
self.assertFalse(form.is_valid())
self.assertIn('new_password_confirm', form.errors)
def test_sets_hashed_password(self):
form = self._form(new_password='GoodPass123!', new_password_confirm='GoodPass123!')
self.assertTrue(form.is_valid(), form.errors)
form.save()
self.employee.refresh_from_db()
self.assertTrue(self.employee.check_password('GoodPass123!'))
self.assertFalse(self.employee.check_password('OldPass123'))
def test_admin_change_view_shows_password_fields(self):
admin_user = make_employee('pwd.admin', password='AdminPass123', is_staff=True, is_superuser=True)
login_2fa(self.client, admin_user)
url = f'/admin/employees/employee/{self.employee.pk}/change/'
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'new_password')
self.assertContains(response, 'Новый пароль')