From 92fb845b70201d22b9e5c27c95b041a8d4de63fe Mon Sep 17 00:00:00 2001 From: lizerk Date: Thu, 14 May 2026 16:32:27 +0300 Subject: [PATCH] Fix birthday event sync lifecycle --- .../management/commands/sync_birthdays.py | 3 + backend/employees/services/birthday_events.py | 82 +++-- backend/employees/signals/birthday.py | 82 ++++- .../tests/employees/test_birthday_events.py | 337 ++++++++++++++++++ 4 files changed, 479 insertions(+), 25 deletions(-) create mode 100644 backend/tests/employees/test_birthday_events.py diff --git a/backend/employees/management/commands/sync_birthdays.py b/backend/employees/management/commands/sync_birthdays.py index 3e3d996d..ce5e9342 100644 --- a/backend/employees/management/commands/sync_birthdays.py +++ b/backend/employees/management/commands/sync_birthdays.py @@ -80,6 +80,9 @@ class Command(BaseCommand): self.stdout.write( self.style.SUCCESS(f" ✓ Обновлено событий: {result['updated']}") ) + self.stdout.write( + self.style.SUCCESS(f" ✓ Удалено устаревших событий: {result['deleted']}") + ) if result["skipped"] > 0: self.stdout.write( diff --git a/backend/employees/services/birthday_events.py b/backend/employees/services/birthday_events.py index b1616c56..8e0e0c04 100644 --- a/backend/employees/services/birthday_events.py +++ b/backend/employees/services/birthday_events.py @@ -3,6 +3,7 @@ Использует паттерн Service Layer через django-service-objects. """ +import calendar as calendar_lib from datetime import datetime, timedelta from django.utils import timezone @@ -14,6 +15,14 @@ from schedule.models import Calendar, Event, Rule # Константы BIRTHDAY_COLOR = "#FFC107" # Жёлтый/золотой цвет для дней рождения +BIRTHDAY_TITLE_PREFIX = "🎂 День рождения:" + + +def birthday_event_queryset(employee: Employee): + """События дня рождения, связанные с сотрудником.""" + return Event.objects.filter( + creator_id=employee.pk, title__startswith=BIRTHDAY_TITLE_PREFIX + ) class UpsertBirthdayEventService(Service): @@ -29,9 +38,15 @@ class UpsertBirthdayEventService(Service): def process(self): employee = self.cleaned_data["employee"] - # Проверяем наличие даты рождения - if not employee.birth_date: - return {"success": False, "reason": "no_birth_date", "event": None} + should_have_event, reason = self._should_have_birthday_event(employee) + if not should_have_event: + deleted_count, _ = birthday_event_queryset(employee).delete() + return { + "success": False, + "reason": reason, + "event": None, + "deleted_count": deleted_count, + } # Получаем общий календарь дней рождений birthday_calendar = self._get_or_create_birthday_calendar() @@ -43,8 +58,22 @@ class UpsertBirthdayEventService(Service): return {"success": True, "event": event, "created": created} + def _should_have_birthday_event(self, employee: Employee) -> tuple[bool, str | None]: + """Проверить, должен ли сотрудник попадать в календарь ДР.""" + if not employee.birth_date: + return False, "no_birth_date" + if not employee.email_verified: + return False, "email_not_verified" + if not employee.is_active: + return False, "not_active" + if not employee.is_actually_active: + return False, "not_actually_active" + return True, None + def _get_or_create_birthday_calendar(self) -> Calendar: """Получить или создать общий календарь дней рождений.""" + # TODO: отдельно настроить доступность календаря birthdays через + # CalendarRelation/CalendarBinding для обычных пользователей. calendar, created = Calendar.objects.get_or_create( slug="birthdays", defaults={"name": "🎂 Дни рождения"} ) @@ -58,29 +87,20 @@ class UpsertBirthdayEventService(Service): Возвращает (event, created). """ - title = f"🎂 День рождения: {str(employee)}" + title = f"{BIRTHDAY_TITLE_PREFIX} {str(employee)}" # Ищем существующее событие по creator + начало title # (паттерн External ID). # НЕ фильтруем по календарю, чтобы найти старые события в персональных # календарях - existing_event = Event.objects.filter( - creator_id=employee.pk, title__startswith="🎂 День рождения:" - ).first() + existing_event = birthday_event_queryset(employee).first() # Создаем правило для ежегодного повторения rule = self._get_or_create_yearly_rule() # Вычисляем даты для текущего года current_year = timezone.now().year - start_date = datetime( - year=current_year, - month=employee.birth_date.month, - day=employee.birth_date.day, - hour=0, - minute=0, - second=0, - ) + start_date = self._birthday_start_for_year(employee, current_year) # Делаем aware ПЕРЕД вычислением end_date if timezone.is_naive(start_date): @@ -103,9 +123,7 @@ class UpsertBirthdayEventService(Service): existing_event.save() # Удаляем дубликаты (другие события ДР этого сотрудника) - Event.objects.filter( - creator_id=employee.pk, title__startswith="🎂 День рождения:" - ).exclude(pk=existing_event.pk).delete() + birthday_event_queryset(employee).exclude(pk=existing_event.pk).delete() return existing_event, False # Обновлено else: @@ -125,6 +143,23 @@ class UpsertBirthdayEventService(Service): ) return event, True # Создано + def _birthday_start_for_year(self, employee: Employee, year: int) -> datetime: + """Дата события в конкретном году с fallback для 29 февраля.""" + birth_date = employee.birth_date + month = birth_date.month + day = birth_date.day + if month == 2 and day == 29 and not calendar_lib.isleap(year): + day = 28 + + return datetime( + year=year, + month=month, + day=day, + hour=0, + minute=0, + second=0, + ) + def _get_or_create_yearly_rule(self) -> Rule: """Получить или создать правило для ежегодного повторения.""" rule, created = Rule.objects.get_or_create( @@ -151,9 +186,7 @@ class DeleteBirthdayEventService(Service): employee = self.cleaned_data["employee"] # Ищем событие по creator + паттерн title - deleted_count, _ = Event.objects.filter( - creator_id=employee.pk, title__startswith="🎂 День рождения:" - ).delete() + deleted_count, _ = birthday_event_queryset(employee).delete() return {"success": deleted_count > 0, "deleted_count": deleted_count} @@ -166,14 +199,13 @@ class BulkSyncBirthdaysService(Service): """ def process(self): - employees = Employee.objects.filter( - birth_date__isnull=False - ).select_related() + employees = Employee.objects.all().select_related() results = { "total": 0, "created": 0, "updated": 0, + "deleted": 0, "skipped": 0, "errors": [], } @@ -190,6 +222,8 @@ class BulkSyncBirthdaysService(Service): results["created"] += 1 else: results["updated"] += 1 + elif result.get("deleted_count", 0): + results["deleted"] += result["deleted_count"] else: results["skipped"] += 1 except Exception as e: diff --git a/backend/employees/signals/birthday.py b/backend/employees/signals/birthday.py index 534658fd..92c1337f 100644 --- a/backend/employees/signals/birthday.py +++ b/backend/employees/signals/birthday.py @@ -11,9 +11,11 @@ from __future__ import annotations import logging -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import post_delete, post_save, pre_save from django.dispatch import receiver +from employees.constants import ACTIVATING_MARKER_ACTIONS, PERMANENT_ACTIONS +from employees.models import Employee from employees.services import ( UpsertBirthdayEventService, DeleteBirthdayEventService, @@ -23,6 +25,19 @@ from employees.services import ( logger = logging.getLogger(__name__) +def _action_affects_birthday_sync(action: str | None) -> bool: + return action in PERMANENT_ACTIONS or action in ACTIVATING_MARKER_ACTIONS + + +def _sync_birthday_for_employee_id(employee_id: int | None) -> None: + if not employee_id: + return + employee = Employee.objects.filter(pk=employee_id).first() + if not employee: + return + UpsertBirthdayEventService.execute({"employee": employee}) + + @receiver(post_save) def sync_birthday_event_on_employee_save(sender, instance, created, **kwargs): """ @@ -52,6 +67,53 @@ def sync_birthday_event_on_employee_save(sender, instance, created, **kwargs): ) +@receiver(pre_save) +def remember_employee_action_for_birthday_sync(sender, instance, **kwargs): + """Запоминает прежнее кадровое действие перед изменением.""" + if sender._meta.label != "employees.EmployeeAction": + return + if not instance.pk: + return + + previous = ( + sender.objects.filter(pk=instance.pk) + .values("action", "employee_id") + .first() + ) + if not previous: + return + + instance._birthday_previous_action = previous["action"] + instance._birthday_previous_employee_id = previous["employee_id"] + + +@receiver(post_save) +def sync_birthday_event_on_employee_action_save(sender, instance, **kwargs): + """ + Синхронизирует ДР при кадровых действиях, меняющих актуальность сотрудника. + """ + if sender._meta.label != "employees.EmployeeAction": + return + + employee_ids = set() + if _action_affects_birthday_sync(instance.action): + employee_ids.add(instance.employee_id) + + previous_action = getattr(instance, "_birthday_previous_action", None) + if _action_affects_birthday_sync(previous_action): + employee_ids.add(getattr(instance, "_birthday_previous_employee_id", None)) + + for employee_id in employee_ids: + try: + _sync_birthday_for_employee_id(employee_id) + except Exception as e: + logger.error( + f"Ошибка при синхронизации дня рождения после кадрового " + f"события #{instance.pk}: {e}", + exc_info=True, + ) + + @receiver(post_delete) def delete_birthday_event_on_employee_delete(sender, instance, **kwargs): """ @@ -76,3 +138,21 @@ def delete_birthday_event_on_employee_delete(sender, instance, **kwargs): f"Ошибка при удалении дня рождения для {instance}: {e}", exc_info=True, ) + + +@receiver(post_delete) +def sync_birthday_event_on_employee_action_delete(sender, instance, **kwargs): + """Пересинхронизирует ДР после удаления статусного кадрового действия.""" + if sender._meta.label != "employees.EmployeeAction": + return + if not _action_affects_birthday_sync(instance.action): + return + + try: + _sync_birthday_for_employee_id(instance.employee_id) + except Exception as e: + logger.error( + f"Ошибка при синхронизации дня рождения после удаления кадрового " + f"события #{instance.pk}: {e}", + exc_info=True, + ) diff --git a/backend/tests/employees/test_birthday_events.py b/backend/tests/employees/test_birthday_events.py new file mode 100644 index 00000000..2fd43585 --- /dev/null +++ b/backend/tests/employees/test_birthday_events.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +from datetime import date, datetime, timedelta +from io import StringIO +from unittest.mock import patch + +import pytest +from django.core.management import call_command +from django.urls import reverse +from django.utils import timezone +from schedule.models import Calendar, Event + +from employees.constants import ACTION_DISMISSED, ACTION_REHIRED +from employees.models import EmployeeAction +from employees.services import BulkSyncBirthdaysService, UpsertBirthdayEventService +from employees.services.birthday_events import ( + BIRTHDAY_COLOR, + BIRTHDAY_TITLE_PREFIX, +) + +pytestmark = pytest.mark.django_db + + +def _birthday_events(employee): + return Event.objects.filter( + creator_id=employee.pk, + title__startswith=BIRTHDAY_TITLE_PREFIX, + ) + + +def _make_stale_birthday_event(employee): + calendar, _ = Calendar.objects.get_or_create( + slug="birthdays", + defaults={"name": "🎂 Дни рождения"}, + ) + start = timezone.now() + return Event.objects.create( + title=f"{BIRTHDAY_TITLE_PREFIX} {employee}", + start=start, + end=start + timedelta(hours=1), + calendar=calendar, + creator=employee, + color_event=BIRTHDAY_COLOR, + ) + + +def _run_sync_birthdays(**options): + output = StringIO() + call_command("sync_birthdays", stdout=output, **options) + return output.getvalue() + + +def _aware_datetime(year: int, month: int, day: int): + return timezone.make_aware(datetime(year, month, day, 12)) + + +def _local_start(event): + return timezone.localtime(event.start) + + +def _local_end(event): + return timezone.localtime(event.end) + + +def test_birthday_event_created_for_actual_employee(user_factory): + employee = user_factory( + first_name="Иван", + last_name="Петров", + birth_date=date(1990, 5, 6), + ) + + event = _birthday_events(employee).get() + + assert event.calendar.slug == "birthdays" + assert event.title == f"{BIRTHDAY_TITLE_PREFIX} Петров Иван" + assert _local_start(event).month == 5 + assert _local_start(event).day == 6 + assert _local_end(event).hour == 23 + assert _local_end(event).minute == 59 + assert event.rule.frequency == "YEARLY" + assert event.end_recurring_period is None + assert event.color_event == BIRTHDAY_COLOR + + +def test_birthday_event_moves_when_birth_date_changes(user_factory): + employee = user_factory(birth_date=date(1990, 5, 6)) + original_event_id = _birthday_events(employee).get().id + + employee.birth_date = date(1990, 7, 8) + employee.save(update_fields=["birth_date"]) + + event = _birthday_events(employee).get() + assert event.id == original_event_id + assert _local_start(event).month == 7 + assert _local_start(event).day == 8 + + +def test_birthday_event_deleted_when_birth_date_is_cleared(user_factory): + employee = user_factory(birth_date=date(1990, 5, 6)) + assert _birthday_events(employee).exists() + + employee.birth_date = None + employee.save(update_fields=["birth_date"]) + + assert not _birthday_events(employee).exists() + + +def test_birthday_event_created_only_after_employee_becomes_actual(user_factory): + employee = user_factory( + verified=False, + active=False, + birth_date=date(1990, 5, 6), + ) + assert not _birthday_events(employee).exists() + + employee.email_verified = True + employee.is_active = True + employee.save(update_fields=["email_verified", "is_active"]) + + assert _birthday_events(employee).count() == 1 + + +def test_birthday_event_deleted_when_employee_is_dismissed(user_factory): + employee = user_factory(birth_date=date(1990, 5, 6)) + assert _birthday_events(employee).exists() + + EmployeeAction.objects.create( + employee=employee, + action=ACTION_DISMISSED, + date=timezone.now(), + ) + + assert not _birthday_events(employee).exists() + + +def test_birthday_event_recreated_when_employee_is_rehired(user_factory): + employee = user_factory(birth_date=date(1990, 5, 6)) + now = timezone.now() + EmployeeAction.objects.create( + employee=employee, + action=ACTION_DISMISSED, + date=now, + ) + assert not _birthday_events(employee).exists() + + EmployeeAction.objects.create( + employee=employee, + action=ACTION_REHIRED, + date=now + timedelta(seconds=1), + ) + + assert _birthday_events(employee).count() == 1 + + +def test_leap_day_birthday_uses_february_28_in_non_leap_year(user_factory): + employee = user_factory(birth_date=date(1988, 2, 29)) + + with patch( + "employees.services.birthday_events.timezone.now", + return_value=_aware_datetime(2026, 1, 1), + ): + UpsertBirthdayEventService.execute({"employee": employee}) + + event = _birthday_events(employee).get() + assert _local_start(event).month == 2 + assert _local_start(event).day == 28 + + +def test_leap_day_birthday_uses_february_29_in_leap_year(user_factory): + employee = user_factory(birth_date=date(1988, 2, 29)) + + with patch( + "employees.services.birthday_events.timezone.now", + return_value=_aware_datetime(2028, 1, 1), + ): + UpsertBirthdayEventService.execute({"employee": employee}) + + event = _birthday_events(employee).get() + assert _local_start(event).month == 2 + assert _local_start(event).day == 29 + + +def test_bulk_sync_deletes_stale_events_and_skips_inactual_employees( + user_factory, +): + active = user_factory(birth_date=date(1990, 5, 6)) + without_birth_date = user_factory(birth_date=None) + dismissed = user_factory(birth_date=date(1991, 7, 8)) + EmployeeAction.objects.create( + employee=dismissed, + action=ACTION_DISMISSED, + date=timezone.now(), + ) + + _make_stale_birthday_event(without_birth_date) + _make_stale_birthday_event(dismissed) + + result = BulkSyncBirthdaysService.execute({}) + + assert _birthday_events(active).count() == 1 + assert not _birthday_events(without_birth_date).exists() + assert not _birthday_events(dismissed).exists() + assert result["deleted"] >= 2 + + +def test_sync_birthdays_command_removes_dismissed_employee_stale_event( + user_factory, +): + employee = user_factory(birth_date=date(1990, 5, 6)) + EmployeeAction.objects.create( + employee=employee, + action=ACTION_DISMISSED, + date=timezone.now(), + ) + stale_event = _make_stale_birthday_event(employee) + assert _birthday_events(employee).filter(pk=stale_event.pk).exists() + + output = _run_sync_birthdays() + + assert not _birthday_events(employee).exists() + assert "Обработано сотрудников: 1" in output + assert "Удалено устаревших событий: 1" in output + assert "Синхронизация завершена успешно" in output + + +def test_sync_birthdays_command_dry_run_keeps_dismissed_stale_event( + user_factory, +): + employee = user_factory(birth_date=date(1990, 5, 6)) + EmployeeAction.objects.create( + employee=employee, + action=ACTION_DISMISSED, + date=timezone.now(), + ) + stale_event = _make_stale_birthday_event(employee) + + output = _run_sync_birthdays(dry_run=True) + + assert _birthday_events(employee).filter(pk=stale_event.pk).exists() + assert "DRY-RUN режим" in output + assert "Удалено устаревших событий: 1" in output + assert "Изменения НЕ применены" in output + + +def test_sync_birthdays_command_handles_mixed_employee_states(user_factory): + active_missing = user_factory(birth_date=date(1990, 5, 6)) + _birthday_events(active_missing).delete() + + active_outdated = user_factory(birth_date=date(1991, 7, 8)) + outdated_event = _birthday_events(active_outdated).get() + outdated_event.start = timezone.make_aware(datetime(2026, 1, 1, 12)) + outdated_event.end = timezone.make_aware(datetime(2026, 1, 1, 13)) + outdated_event.color_event = "#000000" + outdated_event.save() + duplicate_event = _make_stale_birthday_event(active_outdated) + + without_birth_date = user_factory(birth_date=None) + _make_stale_birthday_event(without_birth_date) + + inactive = user_factory(active=False, birth_date=date(1992, 8, 9)) + _make_stale_birthday_event(inactive) + + unverified = user_factory(verified=False, birth_date=date(1993, 9, 10)) + _make_stale_birthday_event(unverified) + + dismissed = user_factory(birth_date=date(1994, 10, 11)) + EmployeeAction.objects.create( + employee=dismissed, + action=ACTION_DISMISSED, + date=timezone.now(), + ) + _make_stale_birthday_event(dismissed) + + output = _run_sync_birthdays() + + assert _birthday_events(active_missing).count() == 1 + + updated_event = _birthday_events(active_outdated).get() + assert updated_event.pk == outdated_event.pk + assert not Event.objects.filter(pk=duplicate_event.pk).exists() + assert _local_start(updated_event).month == 7 + assert _local_start(updated_event).day == 8 + assert updated_event.color_event == BIRTHDAY_COLOR + + assert not _birthday_events(without_birth_date).exists() + assert not _birthday_events(inactive).exists() + assert not _birthday_events(unverified).exists() + assert not _birthday_events(dismissed).exists() + + assert "Обработано сотрудников: 6" in output + assert "Создано событий: 1" in output + assert "Обновлено событий: 1" in output + assert "Удалено устаревших событий: 4" in output + + +def test_profile_patch_moves_birthday_event(api_client, user_factory): + employee = user_factory(birth_date=date(1990, 5, 6)) + api_client.force_authenticate(user=employee) + + response = api_client.patch( + reverse("api:v1:employees-me"), + { + "first_name": employee.first_name, + "last_name": employee.last_name, + "patronymic": employee.patronymic, + "birth_date": "1990-09-10", + }, + format="json", + ) + + assert response.status_code == 200 + event = _birthday_events(employee).get() + assert _local_start(event).month == 9 + assert _local_start(event).day == 10 + + +def test_profile_patch_null_birth_date_deletes_birthday_event( + api_client, + user_factory, +): + employee = user_factory(birth_date=date(1990, 5, 6)) + assert _birthday_events(employee).exists() + api_client.force_authenticate(user=employee) + + response = api_client.patch( + reverse("api:v1:employees-me"), + { + "first_name": employee.first_name, + "last_name": employee.last_name, + "patronymic": employee.patronymic, + "birth_date": None, + }, + format="json", + ) + + assert response.status_code == 200 + assert not _birthday_events(employee).exists()