Fix birthday event sync lifecycle

This commit is contained in:
lizerk 2026-05-14 16:32:27 +03:00
parent c5c4e1f725
commit 92fb845b70
4 changed files with 479 additions and 25 deletions

View File

@ -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(

View File

@ -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:

View File

@ -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,
)

View File

@ -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()