diff --git a/pets/admin.py b/pets/admin.py index e1b58bb..531c050 100644 --- a/pets/admin.py +++ b/pets/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import AnimalTypeEventRule, Event, EventType, Pet, PetEventSetting +from .models import AnimalTypeEventRule, CareRule, Event, EventType, Pet, PetCondition, PetEventSetting, PlannedEvent @admin.register(Pet) @@ -194,6 +194,118 @@ class PetEventSettingAdmin(admin.ModelAdmin): ordering = ("pet__name", "event_type__name") +@admin.register(CareRule) +class CareRuleAdmin(admin.ModelAdmin): + """Настройки отображения правил регулярного ухода.""" + + list_display = ( + "id", + "pet", + "event_type", + "interval_value", + "interval_unit", + "calculate_from", + "start_date", + "is_active", + "created_by", + "updated_at", + ) + list_filter = ( + "event_type", + "interval_unit", + "calculate_from", + "is_active", + "start_date", + "created_at", + ) + search_fields = ( + "pet__name", + "pet__species_name", + "pet__owner__email", + "event_type__slug", + "event_type__name", + "created_by__email", + ) + autocomplete_fields = ("pet", "event_type", "created_by") + readonly_fields = ("created_at", "updated_at") + list_select_related = ("pet", "event_type", "created_by", "pet__owner") + ordering = ("pet__name", "event_type__name", "start_date") + + +@admin.register(PlannedEvent) +class PlannedEventAdmin(admin.ModelAdmin): + """Настройки отображения плановых событий ухода.""" + + list_display = ( + "id", + "pet", + "event_type", + "scheduled_at", + "status", + "care_rule", + "actual_event", + "created_by", + "updated_at", + ) + list_filter = ( + "event_type", + "status", + "scheduled_at", + "created_at", + ) + search_fields = ( + "pet__name", + "pet__species_name", + "pet__owner__email", + "event_type__slug", + "event_type__name", + "title", + "comment", + "created_by__email", + ) + autocomplete_fields = ("pet", "event_type", "care_rule", "actual_event", "created_by") + readonly_fields = ("created_at", "updated_at") + list_select_related = ("pet", "event_type", "care_rule", "actual_event", "created_by", "pet__owner") + ordering = ("scheduled_at",) + + +@admin.register(PetCondition) +class PetConditionAdmin(admin.ModelAdmin): + """Настройки отображения состояний питомцев.""" + + list_display = ( + "id", + "pet", + "condition_type", + "started_at", + "ended_at", + "is_active", + "source_event", + "end_event", + "created_by", + "updated_at", + ) + list_filter = ( + "condition_type", + "is_active", + "started_at", + "ended_at", + "created_at", + ) + search_fields = ( + "pet__name", + "pet__species_name", + "pet__owner__email", + "title", + "comment", + "created_by__email", + ) + autocomplete_fields = ("pet", "source_event", "end_event", "created_by") + readonly_fields = ("created_at", "updated_at") + list_select_related = ("pet", "source_event", "end_event", "created_by", "pet__owner") + ordering = ("-started_at",) + + @admin.register(Event) class EventAdmin(admin.ModelAdmin): """Настройки отображения модели события в админке.""" @@ -204,6 +316,7 @@ class EventAdmin(admin.ModelAdmin): "event_type", "event_type_ref", "event_datetime", + "happened_at", "owner", "repeat_after_days", "no_handling_days", @@ -213,6 +326,7 @@ class EventAdmin(admin.ModelAdmin): "event_type", "event_type_ref", "event_datetime", + "happened_at", "created_at", ) search_fields = ( @@ -240,6 +354,7 @@ class EventAdmin(admin.ModelAdmin): "event_type_ref", "title", "event_datetime", + "happened_at", "comment", ) }, @@ -260,3 +375,18 @@ class EventAdmin(admin.ModelAdmin): {"fields": ("created_at",)}, ), ) + + def formfield_for_dbfield(self, db_field, request, **kwargs): + """Уточняет подписи переходных полей даты события в админке.""" + if db_field.name == "event_datetime": + kwargs["label"] = "Дата и время события (старое поле)" + kwargs["help_text"] = "Старое рабочее поле. Пока должно совпадать с фактическим временем события." + + if db_field.name == "happened_at": + kwargs["label"] = "Фактическая дата и время события" + kwargs["help_text"] = ( + "Новое переходное поле для факта события. " + "Отдельное время завершения будет добавляться позже как новое поле." + ) + + return super().formfield_for_dbfield(db_field, request, **kwargs) diff --git a/pets/forms.py b/pets/forms.py index 3dc157d..8ed3ab3 100644 --- a/pets/forms.py +++ b/pets/forms.py @@ -11,6 +11,11 @@ # ---------------- PET ---------------- +REPEAT_AFTER_DAYS_HELP_TEXT = ( + "Событие будет повторяться через указанный интервал; " + "правило ухода сохранится отдельно от этого события." +) + class PetForm(forms.ModelForm): """Форма для создания и редактирования питомца.""" @@ -237,6 +242,16 @@ def clean_comment(self): """Удаляет лишние пробелы из начала и конца комментария""" return self.cleaned_data.get("comment", "").strip() + def clean(self): + """Синхронизирует переходную дату факта со старым полем формы.""" + cleaned_data = super().clean() + event_datetime = cleaned_data.get("event_datetime") + + if event_datetime: + self.instance.happened_at = event_datetime + + return cleaned_data + # ---------------- SPECIFIC FORMS ---------------- @@ -255,7 +270,7 @@ class Meta(BaseEventForm.Meta): ] help_texts = { "no_handling_days": "Сколько дней нельзя брать питомца после кормления", - "repeat_after_days": "Когда нужно следующее кормление", + "repeat_after_days": REPEAT_AFTER_DAYS_HELP_TEXT, } @@ -287,7 +302,7 @@ class Meta(BaseEventForm.Meta): "comment", ] help_texts = { - "repeat_after_days": "Через сколько дней нужна следующая уборка", + "repeat_after_days": REPEAT_AFTER_DAYS_HELP_TEXT, } @@ -307,6 +322,7 @@ class Meta(BaseEventForm.Meta): help_texts = { "weight_grams": "Можно указать только вес или только длину", "length_cm": "Можно указать только длину или только вес", + "repeat_after_days": REPEAT_AFTER_DAYS_HELP_TEXT, } @@ -324,6 +340,6 @@ class Meta(BaseEventForm.Meta): "comment", ] help_texts = { - "repeat_after_days": "Если поле не нужно — можно оставить пустым.", + "repeat_after_days": REPEAT_AFTER_DAYS_HELP_TEXT, "no_handling_days": "Если поле не нужно — можно оставить пустым.", } diff --git a/pets/management/commands/backfill_care_rules.py b/pets/management/commands/backfill_care_rules.py new file mode 100644 index 0000000..008c80c --- /dev/null +++ b/pets/management/commands/backfill_care_rules.py @@ -0,0 +1,39 @@ +from django.core.management.base import BaseCommand, CommandParser + +from pets.services import backfill_care_rules_from_repeat_events + + +class Command(BaseCommand): + """Создаёт правила ухода из старых повторяющихся событий.""" + + help = "Готовит CareRule из старых событий с repeat_after_days." + + def add_arguments(self, parser: CommandParser) -> None: + """Добавляет флаг применения найденных правил.""" + parser.add_argument( + "--apply", + action="store_true", + help="Создать найденные правила CareRule.", + ) + + def handle(self, *args, **options) -> None: + """Запускает подготовку правил ухода и выводит краткую сводку.""" + result = backfill_care_rules_from_repeat_events(apply_changes=options["apply"]) + mode = "правила созданы" if result.applied else "предпросмотр без записи" + + self.stdout.write(self.style.SUCCESS(f"Подготовка CareRule завершена: {mode}.")) + self.stdout.write(f"Событий с повторяемостью проверено: {result.repeat_events_checked}") + + if result.applied: + self.stdout.write(f"Правил создано: {result.created_rules_count}") + else: + self.stdout.write(f"Правил будет создано: {result.candidate_rules_count}") + + self.stdout.write(f"Пропущено из-за существующих правил: {result.skipped_existing_rules_count}") + self.stdout.write(f"Пропущено без типа события: {result.skipped_missing_event_type_count}") + self.stdout.write(f"Пропущено с неповторяемым типом события: {result.skipped_non_recurring_event_type_count}") + + if not result.applied and result.candidate_rules_count: + self.stdout.write( + self.style.WARNING("Найдены правила для создания. Запустите команду с --apply, чтобы сохранить их.") + ) diff --git a/pets/management/commands/backfill_pet_conditions.py b/pets/management/commands/backfill_pet_conditions.py new file mode 100644 index 0000000..c4beab1 --- /dev/null +++ b/pets/management/commands/backfill_pet_conditions.py @@ -0,0 +1,38 @@ +from django.core.management.base import BaseCommand, CommandParser + +from pets.services import backfill_pet_conditions_from_events + + +class Command(BaseCommand): + """Создаёт активные состояния питомцев из старых событий.""" + + help = "Готовит PetCondition из старых событий с активными no_handling_days." + + def add_arguments(self, parser: CommandParser) -> None: + """Добавляет флаг применения найденных состояний.""" + parser.add_argument( + "--apply", + action="store_true", + help="Создать найденные PetCondition.", + ) + + def handle(self, *args, **options) -> None: + """Запускает подготовку состояний питомцев и выводит краткую сводку.""" + result = backfill_pet_conditions_from_events(apply_changes=options["apply"]) + mode = "состояния созданы" if result.applied else "предпросмотр без записи" + + self.stdout.write(self.style.SUCCESS(f"Подготовка PetCondition завершена: {mode}.")) + self.stdout.write(f"Событий с ограничениями проверено: {result.events_checked}") + + if result.applied: + self.stdout.write(f"Состояний создано: {result.created_conditions_count}") + else: + self.stdout.write(f"Состояний будет создано: {result.candidate_conditions_count}") + + self.stdout.write(f"Пропущено из-за существующих состояний: {result.skipped_existing_conditions_count}") + self.stdout.write(f"Пропущено из-за истёкших ограничений: {result.skipped_expired_conditions_count}") + + if not result.applied and result.candidate_conditions_count: + self.stdout.write( + self.style.WARNING("Найдены состояния для создания. Запустите команду с --apply, чтобы сохранить их.") + ) diff --git a/pets/management/commands/generate_planned_events.py b/pets/management/commands/generate_planned_events.py new file mode 100644 index 0000000..0ef33e7 --- /dev/null +++ b/pets/management/commands/generate_planned_events.py @@ -0,0 +1,41 @@ +from django.core.management.base import BaseCommand, CommandParser + +from pets.services import generate_planned_events_from_care_rules + + +class Command(BaseCommand): + """Создаёт ближайшие плановые задачи из правил ухода.""" + + help = "Готовит PlannedEvent из активных CareRule." + + def add_arguments(self, parser: CommandParser) -> None: + """Добавляет флаг применения найденных задач.""" + parser.add_argument( + "--apply", + action="store_true", + help="Создать найденные PlannedEvent.", + ) + + def handle(self, *args, **options) -> None: + """Запускает подготовку плановых задач и выводит краткую сводку.""" + result = generate_planned_events_from_care_rules(apply_changes=options["apply"]) + mode = "задачи созданы" if result.applied else "предпросмотр без записи" + + self.stdout.write(self.style.SUCCESS(f"Подготовка PlannedEvent завершена: {mode}.")) + self.stdout.write(f"Правил ухода проверено: {result.care_rules_checked}") + + if result.applied: + self.stdout.write(f"Плановых задач создано: {result.created_tasks_count}") + else: + self.stdout.write(f"Плановых задач будет создано: {result.candidate_tasks_count}") + + self.stdout.write(f"Пропущено из-за существующих плановых задач: {result.skipped_existing_tasks_count}") + self.stdout.write(f"Пропущено без даты планирования: {result.skipped_missing_datetime_count}") + self.stdout.write(f"Пропущено с непланируемым типом события: {result.skipped_non_plannable_event_type_count}") + + if not result.applied and result.candidate_tasks_count: + self.stdout.write( + self.style.WARNING( + "Найдены плановые задачи для создания. Запустите команду с --apply, чтобы сохранить их." + ) + ) diff --git a/pets/management/commands/recalculate_pet_statuses.py b/pets/management/commands/recalculate_pet_statuses.py index e94b0cc..530a14f 100644 --- a/pets/management/commands/recalculate_pet_statuses.py +++ b/pets/management/commands/recalculate_pet_statuses.py @@ -31,6 +31,15 @@ def handle(self, *args, **options) -> None: self.stdout.write(f"Активных линек: {result.active_shedding_count}") self.stdout.write(f"Ближайших повторяющихся задач: {result.upcoming_tasks_count}") + if result.applied: + self.stdout.write(f"Правил ухода создано: {result.care_rules_created}") + self.stdout.write(f"Плановых задач создано: {result.planned_events_created}") + self.stdout.write(f"Состояний питомцев создано: {result.pet_conditions_created}") + else: + self.stdout.write(f"Правил ухода будет создано: {result.care_rules_to_create}") + self.stdout.write(f"Плановых задач будет создано: {result.planned_events_to_create}") + self.stdout.write(f"Состояний питомцев будет создано: {result.pet_conditions_to_create}") + if not result.applied and result.pets_with_measurement_changes: self.stdout.write( self.style.WARNING( diff --git a/pets/migrations/0012_carerule.py b/pets/migrations/0012_carerule.py new file mode 100644 index 0000000..e6ae1cb --- /dev/null +++ b/pets/migrations/0012_carerule.py @@ -0,0 +1,92 @@ +# Generated by Django 5.2.13 on 2026-07-29 07:21 + +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pets", "0011_animaltypeeventrule_peteventsetting"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="CareRule", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "interval_value", + models.PositiveIntegerField( + validators=[ + django.core.validators.MinValueValidator(1), + django.core.validators.MaxValueValidator(3650), + ], + verbose_name="Интервал", + ), + ), + ( + "interval_unit", + models.CharField( + choices=[("days", "Дни"), ("weeks", "Недели"), ("months", "Месяцы")], + default="days", + max_length=20, + verbose_name="Единица интервала", + ), + ), + ("start_date", models.DateField(default=django.utils.timezone.localdate, verbose_name="Дата начала")), + ( + "calculate_from", + models.CharField( + choices=[ + ("last_actual_event", "Последнее фактическое событие"), + ("last_planned_date", "Последняя плановая дата"), + ("fixed_schedule", "Фиксированное расписание"), + ], + default="last_actual_event", + max_length=30, + verbose_name="Считать от", + ), + ), + ("is_active", models.BooleanField(default=True, verbose_name="Активно")), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Дата обновления")), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="care_rules", + to=settings.AUTH_USER_MODEL, + verbose_name="Создал", + ), + ), + ( + "event_type", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="care_rules", + to="pets.eventtype", + verbose_name="Тип события", + ), + ), + ( + "pet", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="care_rules", + to="pets.pet", + verbose_name="Питомец", + ), + ), + ], + options={ + "verbose_name": "правило ухода", + "verbose_name_plural": "правила ухода", + "ordering": ["pet__name", "event_type__name", "start_date"], + }, + ), + ] diff --git a/pets/migrations/0013_plannedevent.py b/pets/migrations/0013_plannedevent.py new file mode 100644 index 0000000..37ef5d3 --- /dev/null +++ b/pets/migrations/0013_plannedevent.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.13 on 2026-07-29 07:46 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pets', '0012_carerule'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PlannedEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('scheduled_at', models.DateTimeField(verbose_name='Запланировано на')), + ('status', models.CharField(choices=[('planned', 'Запланировано'), ('done', 'Выполнено'), ('skipped', 'Пропущено'), ('cancelled', 'Отменено')], default='planned', max_length=20, verbose_name='Статус')), + ('title', models.CharField(blank=True, max_length=150, verbose_name='Название задачи')), + ('comment', models.TextField(blank=True, verbose_name='Комментарий')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('actual_event', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='planned_event', to='pets.event', verbose_name='Фактическое событие')), + ('care_rule', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='planned_events', to='pets.carerule', verbose_name='Правило ухода')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='planned_events', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ('event_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='planned_events', to='pets.eventtype', verbose_name='Тип события')), + ('pet', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='planned_events', to='pets.pet', verbose_name='Питомец')), + ], + options={ + 'verbose_name': 'плановое событие', + 'verbose_name_plural': 'плановые события', + 'ordering': ['scheduled_at', 'pet__name', 'event_type__name'], + 'constraints': [models.UniqueConstraint(condition=models.Q(('care_rule__isnull', False), ('status', 'planned')), fields=('care_rule',), name='unique_planned_event_per_active_care_rule')], + }, + ), + ] diff --git a/pets/migrations/0014_petcondition.py b/pets/migrations/0014_petcondition.py new file mode 100644 index 0000000..de866e9 --- /dev/null +++ b/pets/migrations/0014_petcondition.py @@ -0,0 +1,40 @@ +# Generated by Django 5.2.13 on 2026-07-29 07:54 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pets', '0013_plannedevent'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PetCondition', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('condition_type', models.CharField(choices=[('shedding', 'Линька'), ('no_handling', 'Нельзя трогать'), ('treatment', 'Лечение'), ('quarantine', 'Карантин'), ('brumation', 'Зимовка'), ('recovery', 'Восстановление'), ('custom', 'Другое состояние')], max_length=30, verbose_name='Тип состояния')), + ('started_at', models.DateTimeField(verbose_name='Начало')), + ('ended_at', models.DateTimeField(blank=True, null=True, verbose_name='Окончание')), + ('is_active', models.BooleanField(default=True, verbose_name='Активно')), + ('title', models.CharField(blank=True, max_length=150, verbose_name='Название состояния')), + ('comment', models.TextField(blank=True, verbose_name='Комментарий')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pet_conditions', to=settings.AUTH_USER_MODEL, verbose_name='Создал')), + ('end_event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ended_conditions', to='pets.event', verbose_name='Событие завершения')), + ('pet', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conditions', to='pets.pet', verbose_name='Питомец')), + ('source_event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_conditions', to='pets.event', verbose_name='Событие-источник')), + ], + options={ + 'verbose_name': 'состояние питомца', + 'verbose_name_plural': 'состояния питомцев', + 'ordering': ['-started_at', 'pet__name', 'condition_type'], + 'constraints': [models.UniqueConstraint(condition=models.Q(('source_event__isnull', False)), fields=('source_event', 'condition_type'), name='unique_pet_condition_per_source_event_and_type')], + }, + ), + ] diff --git a/pets/migrations/0015_event_happened_at.py b/pets/migrations/0015_event_happened_at.py new file mode 100644 index 0000000..6fc2318 --- /dev/null +++ b/pets/migrations/0015_event_happened_at.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.13 on 2026-07-29 08:12 + +from django.db import migrations, models + + +def copy_event_datetime_to_happened_at(apps, schema_editor): + Event = apps.get_model("pets", "Event") + Event.objects.filter(happened_at__isnull=True).update(happened_at=models.F("event_datetime")) + + +def clear_happened_at(apps, schema_editor): + Event = apps.get_model("pets", "Event") + Event.objects.update(happened_at=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ("pets", "0014_petcondition"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="happened_at", + field=models.DateTimeField( + blank=True, + help_text="Переходное поле для будущей архитектуры событий. Старое поле event_datetime остаётся рабочим.", + null=True, + verbose_name="Когда произошло", + ), + ), + migrations.RunPython(copy_event_datetime_to_happened_at, clear_happened_at), + ] diff --git a/pets/models.py b/pets/models.py index 61dad47..bf43071 100644 --- a/pets/models.py +++ b/pets/models.py @@ -402,6 +402,347 @@ class Meta: verbose_name_plural = "настройки событий питомцев" +class CareRule(models.Model): + """Правило регулярного ухода, отделённое от фактов событий.""" + + class IntervalUnit(models.TextChoices): + DAYS = "days", "Дни" + WEEKS = "weeks", "Недели" + MONTHS = "months", "Месяцы" + + class CalculateFrom(models.TextChoices): + LAST_ACTUAL_EVENT = "last_actual_event", "Последнее фактическое событие" + LAST_PLANNED_DATE = "last_planned_date", "Последняя плановая дата" + FIXED_SCHEDULE = "fixed_schedule", "Фиксированное расписание" + + pet = models.ForeignKey( + Pet, + on_delete=models.CASCADE, + related_name="care_rules", + verbose_name="Питомец", + ) + event_type = models.ForeignKey( + EventType, + on_delete=models.PROTECT, + related_name="care_rules", + verbose_name="Тип события", + ) + interval_value = models.PositiveIntegerField( + verbose_name="Интервал", + validators=[MinValueValidator(1), MaxValueValidator(3650)], + ) + interval_unit = models.CharField( + max_length=20, + choices=IntervalUnit.choices, + default=IntervalUnit.DAYS, + verbose_name="Единица интервала", + ) + start_date = models.DateField( + default=timezone.localdate, + verbose_name="Дата начала", + ) + calculate_from = models.CharField( + max_length=30, + choices=CalculateFrom.choices, + default=CalculateFrom.LAST_ACTUAL_EVENT, + verbose_name="Считать от", + ) + is_active = models.BooleanField( + default=True, + verbose_name="Активно", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="care_rules", + verbose_name="Создал", + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления", + ) + + def clean(self): + """Проверяет, что правило принадлежит владельцу питомца и повторяемому типу события.""" + super().clean() + + if ( + self.pet_id + and self.created_by_id + and self.pet.owner_id != self.created_by_id + and not self.created_by.is_superuser + ): + raise ValidationError({"pet": "Питомец должен принадлежать выбранному владельцу."}) + + if self.event_type_id and not self.event_type.can_be_recurring: + raise ValidationError({"event_type": "Для правила ухода выберите повторяемый тип события."}) + + def __str__(self) -> str: + """Возвращает читаемое представление правила регулярного ухода.""" + return f"{self.pet.name} - {self.event_type.name}: {self.interval_value} {self.get_interval_unit_display()}" + + class Meta: + ordering = ["pet__name", "event_type__name", "start_date"] + verbose_name = "правило ухода" + verbose_name_plural = "правила ухода" + + +class PlannedEvent(models.Model): + """Плановая задача ухода, отделённая от факта события.""" + + class Status(models.TextChoices): + PLANNED = "planned", "Запланировано" + DONE = "done", "Выполнено" + SKIPPED = "skipped", "Пропущено" + CANCELLED = "cancelled", "Отменено" + + pet = models.ForeignKey( + Pet, + on_delete=models.CASCADE, + related_name="planned_events", + verbose_name="Питомец", + ) + event_type = models.ForeignKey( + EventType, + on_delete=models.PROTECT, + related_name="planned_events", + verbose_name="Тип события", + ) + scheduled_at = models.DateTimeField( + verbose_name="Запланировано на", + ) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.PLANNED, + verbose_name="Статус", + ) + care_rule = models.ForeignKey( + CareRule, + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="planned_events", + verbose_name="Правило ухода", + ) + actual_event = models.OneToOneField( + "Event", + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="planned_event", + verbose_name="Фактическое событие", + ) + title = models.CharField( + max_length=150, + blank=True, + verbose_name="Название задачи", + ) + comment = models.TextField( + blank=True, + verbose_name="Комментарий", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="planned_events", + verbose_name="Создал", + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления", + ) + + def clean(self): + """Проверяет согласованность плановой задачи с питомцем, правилом и фактом.""" + super().clean() + + if self.title: + self.title = self.title.strip() + + if self.comment: + self.comment = self.comment.strip() + + if ( + self.pet_id + and self.created_by_id + and self.pet.owner_id != self.created_by_id + and not self.created_by.is_superuser + ): + raise ValidationError({"pet": "Питомец должен принадлежать выбранному владельцу."}) + + if self.event_type_id and not self.event_type.can_be_planned: + raise ValidationError({"event_type": "Для плановой задачи выберите планируемый тип события."}) + + if self.care_rule_id: + if self.care_rule.pet_id != self.pet_id: + raise ValidationError({"care_rule": "Правило ухода должно относиться к выбранному питомцу."}) + + if self.care_rule.event_type_id != self.event_type_id: + raise ValidationError({"care_rule": "Правило ухода должно относиться к выбранному типу события."}) + + if self.actual_event_id: + if self.actual_event.pet_id != self.pet_id: + raise ValidationError({"actual_event": "Фактическое событие должно относиться к выбранному питомцу."}) + + actual_event_type_id = self.actual_event.event_type_ref_id + if actual_event_type_id and actual_event_type_id != self.event_type_id: + raise ValidationError( + {"actual_event": "Фактическое событие должно относиться к выбранному типу события."} + ) + + if not actual_event_type_id and self.actual_event.event_type != self.event_type.slug: + raise ValidationError( + {"actual_event": "Фактическое событие должно относиться к выбранному типу события."} + ) + + def __str__(self) -> str: + """Возвращает читаемое представление плановой задачи.""" + return f"{self.pet.name} - {self.event_type.name}: {timezone.localtime(self.scheduled_at):%d.%m.%Y %H:%M}" + + class Meta: + ordering = ["scheduled_at", "pet__name", "event_type__name"] + constraints = [ + models.UniqueConstraint( + fields=["care_rule"], + condition=models.Q(care_rule__isnull=False, status="planned"), + name="unique_planned_event_per_active_care_rule", + ) + ] + verbose_name = "плановое событие" + verbose_name_plural = "плановые события" + + +class PetCondition(models.Model): + """Активное или историческое состояние питомца, отделённое от фактов событий.""" + + class ConditionType(models.TextChoices): + SHEDDING = "shedding", "Линька" + NO_HANDLING = "no_handling", "Нельзя трогать" + TREATMENT = "treatment", "Лечение" + QUARANTINE = "quarantine", "Карантин" + BRUMATION = "brumation", "Зимовка" + RECOVERY = "recovery", "Восстановление" + CUSTOM = "custom", "Другое состояние" + + pet = models.ForeignKey( + Pet, + on_delete=models.CASCADE, + related_name="conditions", + verbose_name="Питомец", + ) + condition_type = models.CharField( + max_length=30, + choices=ConditionType.choices, + verbose_name="Тип состояния", + ) + started_at = models.DateTimeField( + verbose_name="Начало", + ) + ended_at = models.DateTimeField( + blank=True, + null=True, + verbose_name="Окончание", + ) + is_active = models.BooleanField( + default=True, + verbose_name="Активно", + ) + source_event = models.ForeignKey( + "Event", + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="source_conditions", + verbose_name="Событие-источник", + ) + end_event = models.ForeignKey( + "Event", + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="ended_conditions", + verbose_name="Событие завершения", + ) + title = models.CharField( + max_length=150, + blank=True, + verbose_name="Название состояния", + ) + comment = models.TextField( + blank=True, + verbose_name="Комментарий", + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="pet_conditions", + verbose_name="Создал", + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления", + ) + + def clean(self): + """Проверяет согласованность состояния с питомцем и событиями-источниками.""" + super().clean() + + if self.title: + self.title = self.title.strip() + + if self.comment: + self.comment = self.comment.strip() + + if self.condition_type == self.ConditionType.CUSTOM and not self.title: + raise ValidationError({"title": "Для пользовательского состояния укажите название."}) + + if ( + self.pet_id + and self.created_by_id + and self.pet.owner_id != self.created_by_id + and not self.created_by.is_superuser + ): + raise ValidationError({"pet": "Питомец должен принадлежать выбранному владельцу."}) + + if self.started_at and self.ended_at and self.ended_at <= self.started_at: + raise ValidationError({"ended_at": "Окончание состояния должно быть позже начала."}) + + if self.source_event_id and self.source_event.pet_id != self.pet_id: + raise ValidationError({"source_event": "Событие-источник должно относиться к выбранному питомцу."}) + + if self.end_event_id and self.end_event.pet_id != self.pet_id: + raise ValidationError({"end_event": "Событие завершения должно относиться к выбранному питомцу."}) + + def __str__(self) -> str: + """Возвращает читаемое представление состояния питомца.""" + return f"{self.pet.name} - {self.get_condition_type_display()}: {timezone.localtime(self.started_at):%d.%m.%Y %H:%M}" + + class Meta: + ordering = ["-started_at", "pet__name", "condition_type"] + constraints = [ + models.UniqueConstraint( + fields=["source_event", "condition_type"], + condition=models.Q(source_event__isnull=False), + name="unique_pet_condition_per_source_event_and_type", + ) + ] + verbose_name = "состояние питомца" + verbose_name_plural = "состояния питомцев" + + class Event(models.Model): """Модель события ухода или наблюдения, связанного с конкретным питомцем.""" @@ -452,6 +793,13 @@ class EventType(models.TextChoices): verbose_name="Дата и время события", ) + happened_at = models.DateTimeField( + blank=True, + null=True, + verbose_name="Когда произошло", + help_text="Переходное поле для будущей архитектуры событий. Старое поле event_datetime остаётся рабочим.", + ) + comment = models.TextField( blank=True, verbose_name="Комментарий", @@ -514,9 +862,18 @@ def clean(self): if self.event_type == self.EventType.SHEDDING and self.repeat_after_days: raise ValidationError({"repeat_after_days": "Для линьки повторение обычно не задаётся."}) - if self.pet_id and self.owner_id and self.pet.owner_id != self.owner_id: + if self.pet_id and self.owner_id and self.pet.owner_id != self.owner_id and not self.owner.is_superuser: raise ValidationError({"pet": "Питомец должен принадлежать выбранному владельцу."}) + if self.event_datetime and not self.happened_at: + self.happened_at = self.event_datetime + + if self.happened_at and not self.event_datetime: + self.event_datetime = self.happened_at + + if self.event_datetime and self.happened_at and self.event_datetime != self.happened_at: + raise ValidationError({"happened_at": "Дата факта должна совпадать с текущей датой события."}) + if self.event_datetime and self.event_datetime > timezone.now(): raise ValidationError({"event_datetime": "Дата и время события не могут быть в будущем."}) diff --git a/pets/services.py b/pets/services.py index ca49b0a..304ea94 100644 --- a/pets/services.py +++ b/pets/services.py @@ -1,12 +1,16 @@ +from calendar import monthrange from collections.abc import Sequence from dataclasses import dataclass -from datetime import date, datetime, timedelta +from datetime import date, datetime, time, timedelta from decimal import Decimal from typing import cast +from django.core.exceptions import ValidationError +from django.db import transaction +from django.db.models import Q from django.utils import timezone -from .models import AnimalTypeEventRule, Event, EventType, Pet, PetEventSetting +from .models import AnimalTypeEventRule, CareRule, Event, EventType, Pet, PetCondition, PetEventSetting, PlannedEvent EVENT_SETTINGS_SUBMITTED_FIELD = "event_settings_submitted" EVENT_SETTING_TRACKED_FIELD_TEMPLATE = "event_setting_{slug}_is_tracked" @@ -56,6 +60,11 @@ "button_class": "btn-ui btn-ui--ghost", }, ) +UPCOMING_TASK_LABELS = { + cast(str, Event.EventType.FEEDING): "Покормить", + cast(str, Event.EventType.CLEANING): "Убраться", + cast(str, Event.EventType.MEASUREMENT): "Измерить", +} @dataclass(frozen=True) @@ -67,6 +76,60 @@ class PetStatusRecalculationResult: active_no_handling_count: int active_shedding_count: int upcoming_tasks_count: int + care_rules_to_create: int + care_rules_created: int + planned_events_to_create: int + planned_events_created: int + pet_conditions_to_create: int + pet_conditions_created: int + applied: bool + + +@dataclass(frozen=True) +class CareRuleBackfillResult: + """Хранит сводку подготовки правил ухода из старых повторяющихся событий.""" + + repeat_events_checked: int + candidate_rules_count: int + created_rules_count: int + skipped_existing_rules_count: int + skipped_missing_event_type_count: int + skipped_non_recurring_event_type_count: int + applied: bool + + +@dataclass(frozen=True) +class UpcomingPetTask: + """Хранит ближайшую регулярную задачу питомца.""" + + event_type_slug: str + event_type_name: str + next_datetime: datetime + planned_event_id: int | None = None + + +@dataclass(frozen=True) +class PlannedEventGenerationResult: + """Хранит сводку генерации ближайших плановых задач из правил ухода.""" + + care_rules_checked: int + candidate_tasks_count: int + created_tasks_count: int + skipped_existing_tasks_count: int + skipped_missing_datetime_count: int + skipped_non_plannable_event_type_count: int + applied: bool + + +@dataclass(frozen=True) +class PetConditionBackfillResult: + """Хранит сводку восстановления активных состояний из старых событий.""" + + events_checked: int + candidate_conditions_count: int + created_conditions_count: int + skipped_existing_conditions_count: int + skipped_expired_conditions_count: int applied: bool @@ -139,18 +202,77 @@ def get_owner_display(user) -> str: return user.email +def get_event_happened_at(event: Event) -> datetime: + """Возвращает фактическое время события с fallback на старое поле.""" + return event.happened_at or event.event_datetime + + def get_next_repeat_datetime(event: Event) -> datetime | None: """Вычисляет дату и время следующего повторения события.""" if not event.repeat_after_days: return None - return event.event_datetime + timedelta(days=event.repeat_after_days) + return get_event_happened_at(event) + timedelta(days=event.repeat_after_days) + + +def _add_months(value: datetime, months: int) -> datetime: + """Добавляет календарные месяцы, сохраняя день там, где это возможно.""" + month_index = value.month - 1 + months + year = value.year + month_index // 12 + month = month_index % 12 + 1 + day = min(value.day, monthrange(year, month)[1]) + return value.replace(year=year, month=month, day=day) + + +def _add_care_rule_interval(value: datetime, care_rule: CareRule) -> datetime: + """Добавляет интервал правила ухода к базовой дате.""" + if care_rule.interval_unit == CareRule.IntervalUnit.WEEKS: + return value + timedelta(weeks=care_rule.interval_value) + + if care_rule.interval_unit == CareRule.IntervalUnit.MONTHS: + return _add_months(value, care_rule.interval_value) + + return value + timedelta(days=care_rule.interval_value) + + +def _get_care_rule_start_datetime(care_rule: CareRule) -> datetime: + """Возвращает дату начала правила как timezone-aware datetime.""" + value = datetime.combine(care_rule.start_date, time.min) + return timezone.make_aware(value, timezone.get_current_timezone()) + + +def get_latest_event_for_care_rule(care_rule: CareRule) -> Event | None: + """Возвращает последнее фактическое событие, подходящее под правило ухода.""" + return ( + Event.objects.filter(pet_id=care_rule.pet_id) + .filter(Q(event_type_ref_id=care_rule.event_type_id) | Q(event_type=care_rule.event_type.slug)) + .order_by("-event_datetime", "-pk") + .first() + ) + + +def get_next_care_rule_datetime(care_rule: CareRule) -> datetime | None: + """Вычисляет ближайшую дату по правилу регулярного ухода.""" + if not care_rule.is_active: + return None + + base_dt: datetime | None = None + + if care_rule.calculate_from == CareRule.CalculateFrom.LAST_ACTUAL_EVENT: + latest_event = get_latest_event_for_care_rule(care_rule) + if latest_event: + base_dt = get_event_happened_at(latest_event) + + if base_dt is None: + base_dt = _get_care_rule_start_datetime(care_rule) + + return _add_care_rule_interval(base_dt, care_rule) def get_no_handling_until(event: Event) -> datetime | None: """Возвращает момент окончания периода, когда питомца нельзя трогать.""" if not event.no_handling_days: return None - return event.event_datetime + timedelta(days=event.no_handling_days) + return get_event_happened_at(event) + timedelta(days=event.no_handling_days) def _format_signed_diff(value: Decimal | int | float) -> str: @@ -198,9 +320,13 @@ def recalculate_pet_statuses(*, apply_changes: bool = False) -> PetStatusRecalcu """Пересчитывает производные данные питомцев по истории событий. По умолчанию работает в безопасном режиме предпросмотра и не записывает - изменения в базу. Для сохранения пересчитанных измерений нужно передать - ``apply_changes=True``. + изменения в базу. Для сохранения пересчитанных измерений, правил ухода, + плановых задач и активных состояний нужно передать ``apply_changes=True``. """ + care_rule_result = backfill_care_rules_from_repeat_events(apply_changes=apply_changes) + planned_event_result = generate_planned_events_from_care_rules(apply_changes=apply_changes) + pet_condition_result = backfill_pet_conditions_from_events(apply_changes=apply_changes) + pets_checked = 0 pets_with_measurement_changes = 0 active_no_handling_count = 0 @@ -241,6 +367,351 @@ def recalculate_pet_statuses(*, apply_changes: bool = False) -> PetStatusRecalcu active_no_handling_count=active_no_handling_count, active_shedding_count=active_shedding_count, upcoming_tasks_count=upcoming_tasks_count, + care_rules_to_create=care_rule_result.candidate_rules_count, + care_rules_created=care_rule_result.created_rules_count, + planned_events_to_create=planned_event_result.candidate_tasks_count, + planned_events_created=planned_event_result.created_tasks_count, + pet_conditions_to_create=pet_condition_result.candidate_conditions_count, + pet_conditions_created=pet_condition_result.created_conditions_count, + applied=apply_changes, + ) + + +def backfill_care_rules_from_repeat_events(*, apply_changes: bool = False) -> CareRuleBackfillResult: + """Готовит правила ухода из старых событий с repeat_after_days. + + По умолчанию работает в режиме предпросмотра. При ``apply_changes=True`` + создаёт по одному активному правилу для пары питомец + тип события, + используя последнее фактическое событие с повторяемостью. + """ + repeat_events_checked = 0 + candidate_rules_count = 0 + created_rules_count = 0 + skipped_existing_rules_count = 0 + skipped_missing_event_type_count = 0 + skipped_non_recurring_event_type_count = 0 + seen_keys: set[tuple[int, int]] = set() + existing_rule_keys = set(CareRule.objects.filter(is_active=True).values_list("pet_id", "event_type_id")) + event_types_by_slug = {event_type.slug: event_type for event_type in EventType.objects.all()} + + repeat_events = ( + Event.objects.filter(repeat_after_days__isnull=False) + .select_related("owner", "pet", "event_type_ref") + .order_by("pet_id", "event_type", "-event_datetime", "-pk") + ) + + for event in repeat_events: + repeat_events_checked += 1 + event_type = event.event_type_ref or event_types_by_slug.get(event.event_type) + + if not event_type: + skipped_missing_event_type_count += 1 + continue + + key = (event.pet_id, event_type.id) + if key in seen_keys: + continue + + seen_keys.add(key) + + if not event_type.can_be_recurring: + skipped_non_recurring_event_type_count += 1 + continue + + if key in existing_rule_keys: + skipped_existing_rules_count += 1 + continue + + candidate_rules_count += 1 + + if apply_changes: + CareRule.objects.create( + pet=event.pet, + event_type=event_type, + interval_value=event.repeat_after_days, + interval_unit=CareRule.IntervalUnit.DAYS, + start_date=timezone.localtime(get_event_happened_at(event)).date(), + calculate_from=CareRule.CalculateFrom.LAST_ACTUAL_EVENT, + created_by=event.pet.owner, + ) + created_rules_count += 1 + existing_rule_keys.add(key) + + return CareRuleBackfillResult( + repeat_events_checked=repeat_events_checked, + candidate_rules_count=candidate_rules_count, + created_rules_count=created_rules_count, + skipped_existing_rules_count=skipped_existing_rules_count, + skipped_missing_event_type_count=skipped_missing_event_type_count, + skipped_non_recurring_event_type_count=skipped_non_recurring_event_type_count, + applied=apply_changes, + ) + + +def sync_care_rule_from_event_repeat(event: Event, repeat_after_days: int | None) -> CareRule | None: + """Сохраняет новую повторяемость события как CareRule, а не как свойство факта.""" + if not repeat_after_days: + return None + + event_type = event.event_type_ref or EventType.objects.filter(slug=event.event_type).first() + if not event_type or not event_type.can_be_recurring: + return None + + defaults = { + "interval_value": repeat_after_days, + "interval_unit": CareRule.IntervalUnit.DAYS, + "start_date": timezone.localtime(get_event_happened_at(event)).date(), + "calculate_from": CareRule.CalculateFrom.LAST_ACTUAL_EVENT, + "is_active": True, + } + care_rule = ( + CareRule.objects.filter( + pet=event.pet, + event_type=event_type, + is_active=True, + ) + .order_by("start_date", "pk") + .first() + ) + + if care_rule: + for field, value in defaults.items(): + setattr(care_rule, field, value) + care_rule.save(update_fields=[*defaults, "updated_at"]) + return care_rule + + care_rule = CareRule.objects.create( + pet=event.pet, + event_type=event_type, + created_by=event.pet.owner, + **defaults, + ) + return care_rule + + +def generate_planned_events_from_care_rules(*, apply_changes: bool = False) -> PlannedEventGenerationResult: + """Готовит ближайшие плановые задачи из активных правил ухода. + + По умолчанию работает в режиме предпросмотра. При ``apply_changes=True`` + создаёт не больше одной активной плановой задачи для каждого правила ухода. + Фактические события при этом не создаются. + """ + care_rules_checked = 0 + candidate_tasks_count = 0 + created_tasks_count = 0 + skipped_existing_tasks_count = 0 + skipped_missing_datetime_count = 0 + skipped_non_plannable_event_type_count = 0 + existing_planned_rule_ids = set( + PlannedEvent.objects.filter( + care_rule__isnull=False, + status=PlannedEvent.Status.PLANNED, + ).values_list("care_rule_id", flat=True) + ) + + care_rules = CareRule.objects.select_related("pet", "event_type", "created_by").filter( + is_active=True, + event_type__is_active=True, + ) + + for care_rule in care_rules: + care_rules_checked += 1 + + if not care_rule.event_type.can_be_planned: + skipped_non_plannable_event_type_count += 1 + continue + + if care_rule.id in existing_planned_rule_ids: + skipped_existing_tasks_count += 1 + continue + + scheduled_at = get_next_care_rule_datetime(care_rule) + if not scheduled_at: + skipped_missing_datetime_count += 1 + continue + + candidate_tasks_count += 1 + + if apply_changes: + PlannedEvent.objects.create( + pet=care_rule.pet, + event_type=care_rule.event_type, + scheduled_at=scheduled_at, + status=PlannedEvent.Status.PLANNED, + care_rule=care_rule, + created_by=care_rule.created_by, + ) + created_tasks_count += 1 + existing_planned_rule_ids.add(care_rule.id) + + return PlannedEventGenerationResult( + care_rules_checked=care_rules_checked, + candidate_tasks_count=candidate_tasks_count, + created_tasks_count=created_tasks_count, + skipped_existing_tasks_count=skipped_existing_tasks_count, + skipped_missing_datetime_count=skipped_missing_datetime_count, + skipped_non_plannable_event_type_count=skipped_non_plannable_event_type_count, + applied=apply_changes, + ) + + +def complete_planned_event( + planned_event: PlannedEvent, + *, + completed_at: datetime | None = None, + title: str = "", + comment: str = "", + no_handling_days: int | None = None, + weight_grams: int | None = None, + length_cm: int | None = None, +) -> Event: + """Выполняет плановую задачу: создаёт факт Event и помечает PlannedEvent как done.""" + if planned_event.status != PlannedEvent.Status.PLANNED: + raise ValidationError("Выполнить можно только запланированную задачу.") + + if planned_event.actual_event_id: + raise ValidationError("У плановой задачи уже есть фактическое событие.") + + event_type_slug = planned_event.event_type.slug + event_title = title.strip() if title else planned_event.title + event_comment = comment.strip() if comment else planned_event.comment + event_happened_at = completed_at or timezone.now() + + with transaction.atomic(): + event = Event.objects.create( + owner=planned_event.pet.owner, + pet=planned_event.pet, + event_type=event_type_slug, + event_type_ref=planned_event.event_type, + title=event_title, + event_datetime=event_happened_at, + happened_at=event_happened_at, + comment=event_comment, + repeat_after_days=None, + no_handling_days=no_handling_days, + weight_grams=weight_grams, + length_cm=length_cm, + ) + + planned_event.status = PlannedEvent.Status.DONE + planned_event.actual_event = event + planned_event.save(update_fields=["status", "actual_event", "updated_at"]) + + return event + + +def skip_planned_event(planned_event: PlannedEvent) -> PlannedEvent: + """Помечает плановую задачу как пропущенную без создания фактического события.""" + return _close_planned_event_without_fact(planned_event, PlannedEvent.Status.SKIPPED) + + +def cancel_planned_event(planned_event: PlannedEvent) -> PlannedEvent: + """Помечает плановую задачу как отменённую без создания фактического события.""" + return _close_planned_event_without_fact(planned_event, PlannedEvent.Status.CANCELLED) + + +def _close_planned_event_without_fact(planned_event: PlannedEvent, status: str) -> PlannedEvent: + """Закрывает плановую задачу статусом, который не должен создавать факт Event.""" + if planned_event.status != PlannedEvent.Status.PLANNED: + raise ValidationError("Изменить можно только запланированную задачу.") + + if planned_event.actual_event_id: + raise ValidationError("Плановая задача уже связана с фактическим событием.") + + planned_event.status = status + planned_event.save(update_fields=["status", "updated_at"]) + return planned_event + + +def _get_active_pet_conditions_queryset(pet: Pet, condition_type: str): + """Возвращает активные состояния питомца на текущий момент.""" + now = timezone.now() + return pet.conditions.filter( + condition_type=condition_type, + is_active=True, + started_at__lte=now, + ).filter(Q(ended_at__isnull=True) | Q(ended_at__gt=now)) + + +def pet_has_active_condition(pet: Pet, condition_type: str) -> bool: + """Проверяет, есть ли у питомца активное состояние указанного типа.""" + return _get_active_pet_conditions_queryset(pet, condition_type).exists() + + +def get_pet_condition_until(pet: Pet, condition_type: str) -> datetime | None: + """Возвращает окончание активного состояния или None для бессрочного состояния.""" + active_conditions = list(_get_active_pet_conditions_queryset(pet, condition_type)) + if not active_conditions: + return None + + if any(condition.ended_at is None for condition in active_conditions): + return None + + return max(condition.ended_at for condition in active_conditions if condition.ended_at is not None) + + +def backfill_pet_conditions_from_events(*, apply_changes: bool = False) -> PetConditionBackfillResult: + """Готовит активные состояния питомцев из старых событий. + + По умолчанию работает в режиме предпросмотра. При ``apply_changes=True`` + создаёт состояния только из тех событий, чьи ограничения ещё активны. + """ + events_checked = 0 + candidate_conditions_count = 0 + created_conditions_count = 0 + skipped_existing_conditions_count = 0 + skipped_expired_conditions_count = 0 + existing_condition_keys = set( + PetCondition.objects.filter(source_event__isnull=False).values_list("source_event_id", "condition_type") + ) + now = timezone.now() + + events = ( + Event.objects.filter(no_handling_days__isnull=False) + .select_related("owner", "pet", "event_type_ref") + .order_by("pet_id", "event_type", "-event_datetime", "-pk") + ) + + for event in events: + events_checked += 1 + ended_at = get_no_handling_until(event) + + if not ended_at or ended_at <= now: + skipped_expired_conditions_count += 1 + continue + + condition_types = [PetCondition.ConditionType.NO_HANDLING] + if event.event_type == Event.EventType.SHEDDING: + condition_types.append(PetCondition.ConditionType.SHEDDING) + + for condition_type in condition_types: + key = (event.id, condition_type) + if key in existing_condition_keys: + skipped_existing_conditions_count += 1 + continue + + candidate_conditions_count += 1 + + if apply_changes: + PetCondition.objects.create( + pet=event.pet, + condition_type=condition_type, + started_at=get_event_happened_at(event), + ended_at=ended_at, + is_active=True, + source_event=event, + comment=event.comment, + created_by=event.pet.owner, + ) + created_conditions_count += 1 + existing_condition_keys.add(key) + + return PetConditionBackfillResult( + events_checked=events_checked, + candidate_conditions_count=candidate_conditions_count, + created_conditions_count=created_conditions_count, + skipped_existing_conditions_count=skipped_existing_conditions_count, + skipped_expired_conditions_count=skipped_expired_conditions_count, applied=apply_changes, ) @@ -281,18 +752,54 @@ def get_measurement_comment_lines(event: Event) -> list[str]: return lines -def get_upcoming_pet_tasks(pet: Pet) -> list[str]: - """Возвращает отсортированный список ближайших повторяющихся задач по питомцу - для показа в интерфейсе.""" - event_type_map: Sequence[tuple[str, str]] = ( - (cast(str, Event.EventType.FEEDING), "Покормить"), - (cast(str, Event.EventType.CLEANING), "Убраться"), - (cast(str, Event.EventType.MEASUREMENT), "Измерить"), - ) +def get_upcoming_pet_task_items(pet: Pet) -> list[UpcomingPetTask]: + """Возвращает ближайшие задачи из PlannedEvent, CareRule и старых событий.""" + tasks: list[UpcomingPetTask] = [] + planned_event_type_slugs: set[str] = set() + planned_care_rule_ids: set[int] = set() + care_rule_event_type_slugs: set[str] = set() + + for planned_event in pet.planned_events.select_related("event_type", "care_rule").filter( + status=PlannedEvent.Status.PLANNED, + event_type__is_active=True, + ): + planned_event_type_slugs.add(planned_event.event_type.slug) + if planned_event.care_rule_id: + planned_care_rule_ids.add(planned_event.care_rule_id) + + tasks.append( + UpcomingPetTask( + event_type_slug=planned_event.event_type.slug, + event_type_name=planned_event.event_type.name, + next_datetime=planned_event.scheduled_at, + planned_event_id=planned_event.id, + ) + ) - tasks: list[tuple[datetime, str]] = [] + for care_rule in pet.care_rules.select_related("event_type").filter( + is_active=True, + event_type__is_active=True, + ): + if care_rule.id in planned_care_rule_ids: + continue + + next_dt = get_next_care_rule_datetime(care_rule) + if not next_dt: + continue + + care_rule_event_type_slugs.add(care_rule.event_type.slug) + tasks.append( + UpcomingPetTask( + event_type_slug=care_rule.event_type.slug, + event_type_name=care_rule.event_type.name, + next_datetime=next_dt, + ) + ) + + for event_type, event_type_name in UPCOMING_TASK_LABELS.items(): + if event_type in planned_event_type_slugs or event_type in care_rule_event_type_slugs: + continue - for event_type, label in event_type_map: last_event = ( pet.events.filter( event_type=event_type, @@ -309,10 +816,28 @@ def get_upcoming_pet_tasks(pet: Pet) -> list[str]: if not next_dt: continue - tasks.append((next_dt, f"{label} {timezone.localtime(next_dt).strftime('%d.%m.%Y')}")) + tasks.append( + UpcomingPetTask( + event_type_slug=event_type, + event_type_name=event_type_name, + next_datetime=next_dt, + ) + ) - tasks.sort(key=lambda item: item[0]) - return [text for _, text in tasks] + return sorted(tasks, key=lambda item: item.next_datetime) + + +def get_upcoming_pet_tasks(pet: Pet) -> list[str]: + """Возвращает отсортированный список ближайших повторяющихся задач по питомцу + для показа в интерфейсе.""" + tasks = [] + + for task in get_upcoming_pet_task_items(pet): + label = UPCOMING_TASK_LABELS.get(task.event_type_slug, task.event_type_name) + local_next_dt = timezone.localtime(task.next_datetime) + tasks.append(f"{label} {local_next_dt.strftime('%d.%m.%Y')}") + + return tasks def get_pet_quick_event_actions(pet: Pet) -> list[dict[str, str]]: @@ -518,6 +1043,9 @@ def get_event_comment_display(event: Event) -> str: def get_pet_no_handling_until(pet: Pet) -> datetime | None: """Возвращает самую позднюю дату окончания ограничения на контакт с питомцем.""" + if pet_has_active_condition(pet, PetCondition.ConditionType.NO_HANDLING): + return get_pet_condition_until(pet, PetCondition.ConditionType.NO_HANDLING) + until_values = [ until_dt for event in pet.events.filter(no_handling_days__isnull=False) @@ -532,6 +1060,9 @@ def get_pet_no_handling_until(pet: Pet) -> datetime | None: def pet_can_handle(pet: Pet) -> bool: """Проверяет, можно ли сейчас брать питомца в руки.""" + if pet_has_active_condition(pet, PetCondition.ConditionType.NO_HANDLING): + return False + until_dt = get_pet_no_handling_until(pet) if not until_dt: return True @@ -540,11 +1071,17 @@ def pet_can_handle(pet: Pet) -> bool: def pet_is_in_shedding(pet: Pet) -> bool: """Проверяет, идёт ли у питомца линька с ещё не истекшим ограничением на контакт.""" + if pet_has_active_condition(pet, PetCondition.ConditionType.SHEDDING): + return True + return get_pet_shedding_until(pet) is not None def get_pet_shedding_until(pet: Pet) -> datetime | None: """Возвращает дату окончания текущей линьки питомца, если она ещё не завершилась.""" + if pet_has_active_condition(pet, PetCondition.ConditionType.SHEDDING): + return get_pet_condition_until(pet, PetCondition.ConditionType.SHEDDING) + now = timezone.now() active_until_values = [ until_dt diff --git a/pets/views.py b/pets/views.py index c4a5e3b..ba8b3c7 100644 --- a/pets/views.py +++ b/pets/views.py @@ -1,6 +1,6 @@ -from django.db import transaction from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import PermissionDenied +from django.db import transaction from django.http import Http404, HttpRequest, HttpResponse from django.urls import reverse_lazy from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView @@ -32,6 +32,7 @@ pet_can_handle, pet_is_in_shedding, save_pet_event_settings, + sync_care_rule_from_event_repeat, ) @@ -405,10 +406,14 @@ def get_initial(self): """Заполняет начальные данные формы типом события и питомцем из query-параметров.""" initial = super().get_initial() initial["event_type"] = self.event_type - initial["event_datetime"] = get_user_local_now(self.request.user).replace( - second=0, - microsecond=0, - ).strftime("%Y-%m-%dT%H:%M") + initial["event_datetime"] = ( + get_user_local_now(self.request.user) + .replace( + second=0, + microsecond=0, + ) + .strftime("%Y-%m-%dT%H:%M") + ) pet_id = self.request.GET.get("pet") if pet_id: initial["pet"] = pet_id @@ -426,9 +431,16 @@ def form_valid(self, form): form.add_error(None, availability.error) return self.form_invalid(form) + repeat_after_days = form.cleaned_data.get("repeat_after_days") form.instance.owner = form.cleaned_data["pet"].owner if self.is_moderator() else self.request.user form.instance.event_type = self.event_type - return super().form_valid(form) + form.instance.repeat_after_days = None + + with transaction.atomic(): + response = super().form_valid(form) + sync_care_rule_from_event_repeat(self.object, repeat_after_days) + + return response class EventUpdateView(LoginRequiredMixin, EventOwnerOrModeratorMixin, UpdateView): @@ -454,8 +466,15 @@ def form_valid(self, form): Для модератора владельцем остается владелец выбранного питомца, для обычного пользователя — текущий пользователь. """ + repeat_after_days = form.cleaned_data.get("repeat_after_days") form.instance.owner = form.cleaned_data["pet"].owner if self.is_moderator() else self.request.user - return super().form_valid(form) + form.instance.repeat_after_days = None + + with transaction.atomic(): + response = super().form_valid(form) + sync_care_rule_from_event_repeat(self.object, repeat_after_days) + + return response class EventDeleteView(LoginRequiredMixin, EventOwnerOrModeratorMixin, DeleteView): diff --git a/tests/test_pets_migrations.py b/tests/test_pets_migrations.py index ddfd86b..7de6d63 100644 --- a/tests/test_pets_migrations.py +++ b/tests/test_pets_migrations.py @@ -149,3 +149,54 @@ def test_event_rule_migration_creates_pet_settings_without_changing_events(): } assert PetEventSetting.objects.filter(pet_id=pet.id, is_tracked=True, show_quick_action=True).count() == 5 assert EventType.objects.filter(slug__in={"feeding", "measurement", "cleaning", "shedding", "custom"}).count() == 5 + + +@pytest.mark.django_db(transaction=True) +def test_event_happened_at_migration_copies_legacy_event_datetime(): + migrate_from = [ + ("users", "0007_customuser_email_confirmed_at_and_more"), + ("pets", "0014_petcondition"), + ] + migrate_to = [ + ("users", "0007_customuser_email_confirmed_at_and_more"), + ("pets", "0015_event_happened_at"), + ] + + executor = MigrationExecutor(connection) + executor.migrate(migrate_from) + old_apps = executor.loader.project_state(migrate_from).apps + + User = old_apps.get_model("users", "CustomUser") + Pet = old_apps.get_model("pets", "Pet") + Event = old_apps.get_model("pets", "Event") + + event_datetime = timezone.now() + user = User.objects.create( + email="happened-at-migration@example.com", + password="testpass123", + first_name="Катя", + time_zone="UTC", + ) + pet = Pet.objects.create( + owner=user, + name="Куку", + animal_type="snake", + species_name="Маисовый полоз", + ) + Event.objects.create( + owner=user, + pet=pet, + event_type="feeding", + event_datetime=event_datetime, + comment="Исторический факт", + ) + + executor = MigrationExecutor(connection) + executor.migrate(migrate_to) + new_apps = executor.loader.project_state(migrate_to).apps + + Event = new_apps.get_model("pets", "Event") + event = Event.objects.get() + + assert event.event_datetime == event_datetime + assert event.happened_at == event_datetime diff --git a/tests/test_pets_models.py b/tests/test_pets_models.py index 36248dd..e99912b 100644 --- a/tests/test_pets_models.py +++ b/tests/test_pets_models.py @@ -5,7 +5,16 @@ from django.db import IntegrityError, transaction from django.utils import timezone -from pets.models import AnimalTypeEventRule, Event, EventType, Pet, PetEventSetting +from pets.models import ( + AnimalTypeEventRule, + CareRule, + Event, + EventType, + Pet, + PetCondition, + PetEventSetting, + PlannedEvent, +) SYSTEM_EVENT_TYPE_SLUGS = { Event.EventType.FEEDING, @@ -81,6 +90,21 @@ def test_event_creation_keeps_legacy_type_and_sets_catalog_ref(user, pet): assert event.event_type == Event.EventType.FEEDING assert event.event_type_ref is not None assert event.event_type_ref.slug == Event.EventType.FEEDING + assert event.happened_at == event.event_datetime + + +@pytest.mark.django_db +def test_event_happened_at_must_match_legacy_datetime(user, pet): + event = Event( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now(), + happened_at=timezone.now() - timedelta(days=1), + ) + + with pytest.raises(ValidationError): + event.full_clean() @pytest.mark.django_db @@ -131,6 +155,305 @@ def test_pet_event_setting_is_unique_per_pet_and_event_type(pet): ) +@pytest.mark.django_db +def test_care_rule_stores_recurrence_separately_from_event_history(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=7, + ) + + care_rule = CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=10, + interval_unit=CareRule.IntervalUnit.DAYS, + calculate_from=CareRule.CalculateFrom.LAST_ACTUAL_EVENT, + created_by=user, + ) + + event.refresh_from_db() + + assert care_rule.event_type == feeding_type + assert care_rule.is_active is True + assert event.event_type == Event.EventType.FEEDING + assert event.event_type_ref == feeding_type + assert event.repeat_after_days == 7 + assert Event.objects.count() == 1 + + +@pytest.mark.django_db +def test_care_rule_requires_pet_to_belong_to_creator(user, other_pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + care_rule = CareRule( + pet=other_pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + + with pytest.raises(ValidationError): + care_rule.full_clean() + + +@pytest.mark.django_db +def test_superuser_can_create_care_rule_for_any_pet(django_user_model, other_pet): + superuser = django_user_model.objects.create_superuser( + email="admin@example.com", + password="testpass123", + ) + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + care_rule = CareRule( + pet=other_pet, + event_type=feeding_type, + interval_value=7, + created_by=superuser, + ) + + care_rule.full_clean() + + +@pytest.mark.django_db +def test_care_rule_requires_recurring_event_type(user, pet): + shedding_type = EventType.objects.get(slug=Event.EventType.SHEDDING) + care_rule = CareRule( + pet=pet, + event_type=shedding_type, + interval_value=7, + created_by=user, + ) + + with pytest.raises(ValidationError): + care_rule.full_clean() + + +@pytest.mark.django_db +def test_planned_event_requires_pet_to_belong_to_creator(user, other_pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent( + pet=other_pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + + with pytest.raises(ValidationError): + planned_event.full_clean() + + +@pytest.mark.django_db +def test_superuser_can_create_planned_event_for_any_pet(django_user_model, other_pet): + superuser = django_user_model.objects.create_superuser( + email="admin-planned@example.com", + password="testpass123", + ) + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent( + pet=other_pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=superuser, + ) + + planned_event.full_clean() + + +@pytest.mark.django_db +def test_planned_event_requires_plannable_event_type(user, pet): + shedding_type = EventType.objects.get(slug=Event.EventType.SHEDDING) + planned_event = PlannedEvent( + pet=pet, + event_type=shedding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + + with pytest.raises(ValidationError): + planned_event.full_clean() + + +@pytest.mark.django_db +def test_planned_event_care_rule_must_match_pet_and_event_type(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + cleaning_type = EventType.objects.get(slug=Event.EventType.CLEANING) + care_rule = CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + planned_event = PlannedEvent( + pet=pet, + event_type=cleaning_type, + scheduled_at=timezone.now() + timedelta(days=1), + care_rule=care_rule, + created_by=user, + ) + + with pytest.raises(ValidationError): + planned_event.full_clean() + + +@pytest.mark.django_db +def test_planned_event_actual_event_must_match_event_type(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + cleaning_type = EventType.objects.get(slug=Event.EventType.CLEANING) + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CLEANING, + event_datetime=timezone.now(), + ) + Event.objects.filter(pk=event.pk).update(event_type_ref=None) + event.refresh_from_db() + planned_event = PlannedEvent( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + actual_event=event, + created_by=user, + ) + + assert event.event_type == cleaning_type.slug + assert event.event_type_ref_id is None + + with pytest.raises(ValidationError): + planned_event.full_clean() + + +@pytest.mark.django_db +def test_only_one_planned_event_is_allowed_per_active_care_rule(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + care_rule = CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + care_rule=care_rule, + created_by=user, + ) + + with pytest.raises(IntegrityError), transaction.atomic(): + PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=2), + care_rule=care_rule, + created_by=user, + ) + + +@pytest.mark.django_db +def test_pet_condition_requires_pet_to_belong_to_creator(user, other_pet): + condition = PetCondition( + pet=other_pet, + condition_type=PetCondition.ConditionType.NO_HANDLING, + started_at=timezone.now(), + created_by=user, + ) + + with pytest.raises(ValidationError): + condition.full_clean() + + +@pytest.mark.django_db +def test_superuser_can_create_pet_condition_for_any_pet(django_user_model, other_pet): + superuser = django_user_model.objects.create_superuser( + email="admin-condition@example.com", + password="testpass123", + ) + condition = PetCondition( + pet=other_pet, + condition_type=PetCondition.ConditionType.NO_HANDLING, + started_at=timezone.now(), + created_by=superuser, + ) + + condition.full_clean() + + +@pytest.mark.django_db +def test_pet_condition_ended_at_must_be_after_started_at(user, pet): + started_at = timezone.now() + condition = PetCondition( + pet=pet, + condition_type=PetCondition.ConditionType.NO_HANDLING, + started_at=started_at, + ended_at=started_at, + created_by=user, + ) + + with pytest.raises(ValidationError): + condition.full_clean() + + +@pytest.mark.django_db +def test_pet_condition_source_event_must_belong_to_pet(user, pet, other_pet): + event = Event.objects.create( + owner=other_pet.owner, + pet=other_pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now(), + ) + condition = PetCondition( + pet=pet, + condition_type=PetCondition.ConditionType.NO_HANDLING, + started_at=timezone.now(), + source_event=event, + created_by=user, + ) + + with pytest.raises(ValidationError): + condition.full_clean() + + +@pytest.mark.django_db +def test_custom_pet_condition_requires_title(user, pet): + condition = PetCondition( + pet=pet, + condition_type=PetCondition.ConditionType.CUSTOM, + started_at=timezone.now(), + created_by=user, + ) + + with pytest.raises(ValidationError): + condition.full_clean() + + +@pytest.mark.django_db +def test_pet_condition_is_unique_per_source_event_and_type(user, pet): + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now(), + ) + PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.NO_HANDLING, + started_at=timezone.now(), + source_event=event, + created_by=user, + ) + + with pytest.raises(IntegrityError), transaction.atomic(): + PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.NO_HANDLING, + started_at=timezone.now(), + source_event=event, + created_by=user, + ) + + @pytest.mark.django_db def test_measurement_event_requires_weight_or_length(user, pet): event = Event( @@ -157,6 +480,22 @@ def test_event_datetime_cannot_be_in_future(user, pet): event.full_clean() +@pytest.mark.django_db +def test_superuser_can_create_event_for_any_pet(django_user_model, other_pet): + superuser = django_user_model.objects.create_superuser( + email="admin-event@example.com", + password="testpass123", + ) + event = Event( + owner=superuser, + pet=other_pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now(), + ) + + event.full_clean() + + @pytest.mark.django_db def test_measurement_event_updates_pet_measurements(user, pet): Event.objects.create( diff --git a/tests/test_pets_services.py b/tests/test_pets_services.py index 6a0aad3..f4778f9 100644 --- a/tests/test_pets_services.py +++ b/tests/test_pets_services.py @@ -3,23 +3,34 @@ from io import StringIO import pytest +from django.core.exceptions import ValidationError from django.core.management import call_command from django.utils import timezone -from pets.models import Event +from pets.models import CareRule, Event, EventType, PetCondition, PlannedEvent from pets.services import ( _format_signed_diff, + backfill_care_rules_from_repeat_events, + backfill_pet_conditions_from_events, + cancel_planned_event, + complete_planned_event, + generate_planned_events_from_care_rules, get_event_comment_display, + get_event_happened_at, get_measurement_comment_lines, + get_next_care_rule_datetime, get_next_repeat_datetime, get_no_handling_until, get_pet_age_display, get_pet_no_handling_until, get_pet_shedding_until, + get_upcoming_pet_task_items, get_upcoming_pet_tasks, pet_can_handle, pet_is_in_shedding, recalculate_pet_statuses, + skip_planned_event, + sync_care_rule_from_event_repeat, ) @@ -61,12 +72,59 @@ def test_get_next_repeat_datetime(feeding_event): assert next_dt == feeding_event.event_datetime + timedelta(days=feeding_event.repeat_after_days) +@pytest.mark.django_db +def test_get_next_repeat_datetime_uses_happened_at(user, pet): + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=10), + repeat_after_days=7, + ) + happened_at = timezone.now() - timedelta(days=1) + Event.objects.filter(pk=event.pk).update(happened_at=happened_at) + event.refresh_from_db() + + assert get_event_happened_at(event) == happened_at + assert get_next_repeat_datetime(event) == happened_at + timedelta(days=7) + + +@pytest.mark.django_db +def test_get_event_happened_at_falls_back_to_legacy_datetime(user, pet): + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + ) + Event.objects.filter(pk=event.pk).update(happened_at=None) + event.refresh_from_db() + + assert get_event_happened_at(event) == event.event_datetime + + @pytest.mark.django_db def test_get_no_handling_until(feeding_event): until_dt = get_no_handling_until(feeding_event) assert until_dt == feeding_event.event_datetime + timedelta(days=feeding_event.no_handling_days) +@pytest.mark.django_db +def test_get_no_handling_until_uses_happened_at(user, pet): + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=10), + no_handling_days=2, + ) + happened_at = timezone.now() - timedelta(days=1) + Event.objects.filter(pk=event.pk).update(happened_at=happened_at) + event.refresh_from_db() + + assert get_no_handling_until(event) == happened_at + timedelta(days=2) + + @pytest.mark.django_db def test_get_measurement_comment_lines_without_previous(measurement_event): lines = get_measurement_comment_lines(measurement_event) @@ -172,6 +230,9 @@ def test_recalculate_pet_statuses_command_does_not_write_by_default(user, pet): assert "предпросмотр без записи" in output.getvalue() assert "Питомцев с изменениями измерений: 1" in output.getvalue() + assert "Правил ухода будет создано: 0" in output.getvalue() + assert "Плановых задач будет создано: 0" in output.getvalue() + assert "Состояний питомцев будет создано: 0" in output.getvalue() assert pet.weight_grams == 10 @@ -195,6 +256,482 @@ def test_recalculate_pet_statuses_command_applies_changes(user, pet): assert pet.weight_grams == 30 +@pytest.mark.django_db +def test_recalculate_pet_statuses_previews_architecture_backfills(user, pet): + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=7, + no_handling_days=3, + ) + + result = recalculate_pet_statuses() + + assert result.applied is False + assert result.care_rules_to_create == 1 + assert result.care_rules_created == 0 + assert result.pet_conditions_to_create == 1 + assert result.pet_conditions_created == 0 + assert CareRule.objects.count() == 0 + assert PlannedEvent.objects.count() == 0 + assert PetCondition.objects.count() == 0 + + +@pytest.mark.django_db +def test_recalculate_pet_statuses_applies_architecture_backfills(user, pet): + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=7, + no_handling_days=3, + ) + + result = recalculate_pet_statuses(apply_changes=True) + + assert result.applied is True + assert result.care_rules_created == 1 + assert result.planned_events_created == 1 + assert result.pet_conditions_created == 1 + assert CareRule.objects.count() == 1 + assert PlannedEvent.objects.count() == 1 + assert Event.objects.count() == 1 + assert PetCondition.objects.count() == 1 + + +@pytest.mark.django_db +def test_backfill_care_rules_previews_without_writing(user, pet): + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=7, + ) + + result = backfill_care_rules_from_repeat_events() + + assert result.applied is False + assert result.repeat_events_checked == 1 + assert result.candidate_rules_count == 1 + assert result.created_rules_count == 0 + assert CareRule.objects.count() == 0 + + +@pytest.mark.django_db +def test_backfill_care_rules_creates_rule_from_latest_repeat_event(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=5), + repeat_after_days=7, + ) + latest_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=10, + ) + happened_at = timezone.now() - timedelta(days=2) + Event.objects.filter(pk=latest_event.pk).update(happened_at=happened_at) + latest_event.refresh_from_db() + + result = backfill_care_rules_from_repeat_events(apply_changes=True) + care_rule = CareRule.objects.get() + + assert result.applied is True + assert result.repeat_events_checked == 2 + assert result.candidate_rules_count == 1 + assert result.created_rules_count == 1 + assert care_rule.pet == pet + assert care_rule.event_type == feeding_type + assert care_rule.interval_value == 10 + assert care_rule.interval_unit == CareRule.IntervalUnit.DAYS + assert care_rule.calculate_from == CareRule.CalculateFrom.LAST_ACTUAL_EVENT + assert care_rule.start_date == timezone.localtime(happened_at).date() + assert care_rule.created_by == user + + +@pytest.mark.django_db +def test_backfill_care_rules_uses_pet_owner_as_rule_creator(user, other_user, pet): + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=7, + ) + Event.objects.filter(pk=event.pk).update(owner=other_user) + + backfill_care_rules_from_repeat_events(apply_changes=True) + + assert CareRule.objects.get().created_by == user + + +@pytest.mark.django_db +def test_backfill_care_rules_skips_existing_active_rule(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=10, + ) + + result = backfill_care_rules_from_repeat_events(apply_changes=True) + + assert result.candidate_rules_count == 0 + assert result.created_rules_count == 0 + assert result.skipped_existing_rules_count == 1 + assert CareRule.objects.count() == 1 + + +@pytest.mark.django_db +def test_backfill_care_rules_command_does_not_write_by_default(user, pet): + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=7, + ) + output = StringIO() + + call_command("backfill_care_rules", stdout=output) + + assert "предпросмотр без записи" in output.getvalue() + assert "Правил будет создано: 1" in output.getvalue() + assert CareRule.objects.count() == 0 + + +@pytest.mark.django_db +def test_sync_care_rule_from_event_repeat_updates_rule(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + ) + CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + + care_rule = sync_care_rule_from_event_repeat(event, 14) + + assert care_rule is not None + assert care_rule.interval_value == 14 + assert CareRule.objects.count() == 1 + + +@pytest.mark.django_db +def test_generate_planned_events_previews_without_writing(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + ) + CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + + result = generate_planned_events_from_care_rules() + + assert result.applied is False + assert result.care_rules_checked == 1 + assert result.candidate_tasks_count == 1 + assert result.created_tasks_count == 0 + assert PlannedEvent.objects.count() == 0 + + +@pytest.mark.django_db +def test_generate_planned_events_creates_nearest_task_from_care_rule(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + ) + care_rule = CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + + result = generate_planned_events_from_care_rules(apply_changes=True) + planned_event = PlannedEvent.objects.get() + + assert result.applied is True + assert result.candidate_tasks_count == 1 + assert result.created_tasks_count == 1 + assert planned_event.pet == pet + assert planned_event.event_type == feeding_type + assert planned_event.care_rule == care_rule + assert planned_event.scheduled_at == event.event_datetime + timedelta(days=7) + assert planned_event.status == PlannedEvent.Status.PLANNED + assert planned_event.actual_event is None + assert Event.objects.count() == 1 + + +@pytest.mark.django_db +def test_generate_planned_events_skips_existing_planned_task(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + care_rule = CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=7), + care_rule=care_rule, + created_by=user, + ) + + result = generate_planned_events_from_care_rules(apply_changes=True) + + assert result.candidate_tasks_count == 0 + assert result.created_tasks_count == 0 + assert result.skipped_existing_tasks_count == 1 + assert PlannedEvent.objects.count() == 1 + + +@pytest.mark.django_db +def test_generate_planned_events_command_does_not_write_by_default(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + output = StringIO() + + call_command("generate_planned_events", stdout=output) + + assert "предпросмотр без записи" in output.getvalue() + assert "Плановых задач будет создано: 1" in output.getvalue() + assert PlannedEvent.objects.count() == 0 + + +@pytest.mark.django_db +def test_complete_planned_event_creates_actual_event_and_marks_done(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + comment="Плановое кормление", + created_by=user, + ) + completed_at = timezone.now() - timedelta(minutes=5) + + event = complete_planned_event( + planned_event, + completed_at=completed_at, + no_handling_days=2, + ) + planned_event.refresh_from_db() + + assert event.pet == pet + assert event.owner == user + assert event.event_type == Event.EventType.FEEDING + assert event.event_type_ref == feeding_type + assert event.event_datetime == completed_at + assert event.happened_at == completed_at + assert event.comment == "Плановое кормление" + assert event.repeat_after_days is None + assert event.no_handling_days == 2 + assert planned_event.status == PlannedEvent.Status.DONE + assert planned_event.actual_event == event + + +@pytest.mark.django_db +def test_complete_custom_planned_event_uses_task_title(user, pet): + custom_type = EventType.objects.get(slug=Event.EventType.CUSTOM) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=custom_type, + scheduled_at=timezone.now() + timedelta(days=1), + title="Заменить лампу", + created_by=user, + ) + + event = complete_planned_event(planned_event, completed_at=timezone.now()) + + assert event.event_type == Event.EventType.CUSTOM + assert event.title == "Заменить лампу" + + +@pytest.mark.django_db +def test_complete_planned_event_requires_planned_status(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + status=PlannedEvent.Status.CANCELLED, + created_by=user, + ) + + with pytest.raises(ValidationError): + complete_planned_event(planned_event) + + assert Event.objects.count() == 0 + + +@pytest.mark.django_db +def test_skip_planned_event_does_not_create_actual_event(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + + skip_planned_event(planned_event) + planned_event.refresh_from_db() + + assert planned_event.status == PlannedEvent.Status.SKIPPED + assert planned_event.actual_event is None + assert Event.objects.count() == 0 + + +@pytest.mark.django_db +def test_cancel_planned_event_does_not_create_actual_event(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + + cancel_planned_event(planned_event) + planned_event.refresh_from_db() + + assert planned_event.status == PlannedEvent.Status.CANCELLED + assert planned_event.actual_event is None + assert Event.objects.count() == 0 + + +@pytest.mark.django_db +def test_backfill_pet_conditions_previews_without_writing(user, pet): + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + no_handling_days=3, + ) + + result = backfill_pet_conditions_from_events() + + assert result.applied is False + assert result.events_checked == 1 + assert result.candidate_conditions_count == 1 + assert result.created_conditions_count == 0 + assert PetCondition.objects.count() == 0 + + +@pytest.mark.django_db +def test_backfill_pet_conditions_creates_active_conditions_from_events(user, pet): + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.SHEDDING, + event_datetime=timezone.now() - timedelta(days=1), + no_handling_days=3, + comment="Активная линька", + ) + + result = backfill_pet_conditions_from_events(apply_changes=True) + condition_types = set(PetCondition.objects.values_list("condition_type", flat=True)) + + assert result.applied is True + assert result.events_checked == 1 + assert result.candidate_conditions_count == 2 + assert result.created_conditions_count == 2 + assert condition_types == { + PetCondition.ConditionType.NO_HANDLING, + PetCondition.ConditionType.SHEDDING, + } + assert all(condition.source_event == event for condition in PetCondition.objects.all()) + assert all(condition.created_by == user for condition in PetCondition.objects.all()) + assert all(condition.ended_at == get_no_handling_until(event) for condition in PetCondition.objects.all()) + assert Event.objects.count() == 1 + + +@pytest.mark.django_db +def test_backfill_pet_conditions_skips_existing_condition(user, pet): + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + no_handling_days=3, + ) + PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.NO_HANDLING, + started_at=event.event_datetime, + ended_at=get_no_handling_until(event), + source_event=event, + created_by=user, + ) + + result = backfill_pet_conditions_from_events(apply_changes=True) + + assert result.candidate_conditions_count == 0 + assert result.created_conditions_count == 0 + assert result.skipped_existing_conditions_count == 1 + assert PetCondition.objects.count() == 1 + + +@pytest.mark.django_db +def test_backfill_pet_conditions_command_does_not_write_by_default(user, pet): + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + no_handling_days=3, + ) + output = StringIO() + + call_command("backfill_pet_conditions", stdout=output) + + assert "предпросмотр без записи" in output.getvalue() + assert "Состояний будет создано: 1" in output.getvalue() + assert PetCondition.objects.count() == 0 + + @pytest.mark.django_db def test_pet_can_handle_false_when_no_handling_is_active(shedding_event, pet): assert pet_can_handle(pet) is False @@ -236,6 +773,42 @@ def test_pet_no_handling_uses_latest_active_limit_not_latest_event(user, pet): assert pet_can_handle(pet) is False +@pytest.mark.django_db +def test_pet_no_handling_uses_pet_condition_before_legacy_events(user, pet): + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=10), + no_handling_days=30, + ) + condition_until = timezone.now() + timedelta(days=2) + PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.NO_HANDLING, + started_at=timezone.now() - timedelta(days=1), + ended_at=condition_until, + created_by=user, + ) + + assert get_pet_no_handling_until(pet) == condition_until + assert pet_can_handle(pet) is False + + +@pytest.mark.django_db +def test_pet_can_handle_false_for_indefinite_no_handling_condition(user, pet): + PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.NO_HANDLING, + started_at=timezone.now() - timedelta(days=1), + ended_at=None, + created_by=user, + ) + + assert get_pet_no_handling_until(pet) is None + assert pet_can_handle(pet) is False + + @pytest.mark.django_db def test_pet_is_in_shedding_true_when_shedding_is_active(shedding_event, pet): assert pet_is_in_shedding(pet) is True @@ -276,6 +849,28 @@ def test_pet_shedding_until_uses_active_shedding_not_latest_event(user, pet): assert pet_is_in_shedding(pet) is True +@pytest.mark.django_db +def test_pet_shedding_uses_pet_condition_before_legacy_events(user, pet): + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.SHEDDING, + event_datetime=timezone.now() - timedelta(days=10), + no_handling_days=30, + ) + condition_until = timezone.now() + timedelta(days=2) + PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.SHEDDING, + started_at=timezone.now() - timedelta(days=1), + ended_at=condition_until, + created_by=user, + ) + + assert get_pet_shedding_until(pet) == condition_until + assert pet_is_in_shedding(pet) is True + + @pytest.mark.django_db def test_get_upcoming_pet_tasks_returns_sorted_tasks(user, pet): Event.objects.create( @@ -300,6 +895,113 @@ def test_get_upcoming_pet_tasks_returns_sorted_tasks(user, pet): assert tasks[1].startswith("Убраться") +@pytest.mark.django_db +def test_get_next_care_rule_datetime_uses_latest_matching_event(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + older_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=10), + ) + latest_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + ) + happened_at = timezone.now() - timedelta(hours=12) + Event.objects.filter(pk=latest_event.pk).update(happened_at=happened_at) + latest_event.refresh_from_db() + care_rule = CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + + assert older_event.event_datetime < latest_event.event_datetime + assert get_next_care_rule_datetime(care_rule) == happened_at + timedelta(days=7) + + +@pytest.mark.django_db +def test_get_upcoming_pet_tasks_prefers_care_rule_over_legacy_repeat(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=30, + ) + happened_at = timezone.now() - timedelta(hours=12) + Event.objects.filter(pk=event.pk).update(happened_at=happened_at) + event.refresh_from_db() + CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + + tasks = get_upcoming_pet_task_items(pet) + + assert len(tasks) == 1 + assert tasks[0].event_type_slug == Event.EventType.FEEDING + assert tasks[0].next_datetime == happened_at + timedelta(days=7) + + +@pytest.mark.django_db +def test_get_upcoming_pet_tasks_falls_back_to_legacy_repeat(user, pet): + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=7, + ) + happened_at = timezone.now() - timedelta(hours=12) + Event.objects.filter(pk=event.pk).update(happened_at=happened_at) + event.refresh_from_db() + + tasks = get_upcoming_pet_task_items(pet) + + assert len(tasks) == 1 + assert tasks[0].event_type_slug == Event.EventType.FEEDING + assert tasks[0].next_datetime == happened_at + timedelta(days=7) + + +@pytest.mark.django_db +def test_get_upcoming_pet_tasks_prefers_planned_event_over_care_rule_and_legacy(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=30, + ) + care_rule = CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=2), + care_rule=care_rule, + created_by=user, + ) + + tasks = get_upcoming_pet_task_items(pet) + + assert len(tasks) == 1 + assert tasks[0].planned_event_id == planned_event.id + assert tasks[0].next_datetime == planned_event.scheduled_at + + @pytest.mark.django_db def test_get_event_comment_display_contains_comment_parts(feeding_event): text = get_event_comment_display(feeding_event) diff --git a/tests/test_pets_views.py b/tests/test_pets_views.py index c16d1bb..4cb6e47 100644 --- a/tests/test_pets_views.py +++ b/tests/test_pets_views.py @@ -6,7 +6,7 @@ from django.utils import timezone from legal.models import LegalDocumentVersion, UserLegalAcceptance -from pets.models import AnimalTypeEventRule, Event, EventType, Pet, PetEventSetting +from pets.models import AnimalTypeEventRule, CareRule, Event, EventType, Pet, PetEventSetting SYSTEM_EVENT_SETTING_SLUGS = ( "feeding", @@ -546,6 +546,11 @@ def test_event_create_for_user_uses_request_user_as_owner( assert event.owner == user assert event.pet == pet assert event.event_type == Event.EventType.FEEDING + assert event.repeat_after_days is None + + care_rule = CareRule.objects.get(pet=pet, event_type__slug=Event.EventType.FEEDING) + assert care_rule.interval_value == 7 + assert care_rule.created_by == user @pytest.mark.django_db @@ -769,12 +774,15 @@ def test_event_create_is_blocked_when_animal_type_rule_disallows_event( assert post_response.status_code == 200 assert "Это событие недоступно для змей" in post_response.content.decode() - assert Event.objects.filter( - owner=user, - pet=pet, - event_type=Event.EventType.FEEDING, - comment="Запрещённое кормление", - ).exists() is False + assert ( + Event.objects.filter( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + comment="Запрещённое кормление", + ).exists() + is False + ) @pytest.mark.django_db @@ -833,6 +841,46 @@ def test_event_update_keeps_existing_datetime( assert 'value="2026-07-27T08:30"' in response.content.decode() +@pytest.mark.django_db +def test_event_update_saves_repeat_as_care_rule( + auth_client, + user, + pet, +) -> None: + """Редактирование события сохраняет новую регулярность в CareRule.""" + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now(), + repeat_after_days=7, + no_handling_days=1, + ) + + response = auth_client.post( + reverse( + "pets:event_update", + kwargs={"pk": event.pk}, + ), + data={ + "pet": pet.pk, + "event_type": Event.EventType.FEEDING, + "event_datetime": timezone.localtime(event.event_datetime).strftime("%Y-%m-%dT%H:%M"), + "no_handling_days": 1, + "repeat_after_days": 14, + "comment": "Обновить регулярность", + }, + ) + + event.refresh_from_db() + care_rule = CareRule.objects.get(pet=pet, event_type__slug=Event.EventType.FEEDING) + + assert response.status_code == 302 + assert event.repeat_after_days is None + assert care_rule.interval_value == 14 + assert care_rule.created_by == user + + @pytest.mark.django_db def test_pet_list_pet_name_links_to_detail( auth_client, diff --git a/tests/test_users_services.py b/tests/test_users_services.py index 284cff6..6e504ed 100644 --- a/tests/test_users_services.py +++ b/tests/test_users_services.py @@ -1,13 +1,15 @@ +from datetime import timedelta from unittest.mock import Mock, patch import pytest -from django.core.exceptions import ValidationError from django.core import mail +from django.core.exceptions import ValidationError from django.utils import timezone +from pets.models import CareRule, Event, EventType, PlannedEvent from users.services import ( - build_email_confirmation_url, build_daily_care_notification_text, + build_email_confirmation_url, build_pet_notification_block, build_telegram_welcome_text, confirm_user_email, @@ -268,6 +270,58 @@ def test_get_pet_tasks_for_today_always_contains_change_water(user, pet): assert "поменяй воду" in tasks +@pytest.mark.django_db +def test_get_pet_tasks_for_today_uses_care_rule(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=30, + ) + CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=1, + created_by=user, + ) + + tasks = get_pet_tasks_for_today(pet, user, local_now=timezone.now()) + + assert "покормить" in tasks + + +@pytest.mark.django_db +def test_get_pet_tasks_for_today_uses_planned_event(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now(), + created_by=user, + ) + + tasks = get_pet_tasks_for_today(pet, user, local_now=timezone.now()) + + assert "покормить" in tasks + + +@pytest.mark.django_db +def test_get_pet_tasks_for_today_falls_back_to_legacy_repeat(user, pet): + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CLEANING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=1, + ) + + tasks = get_pet_tasks_for_today(pet, user, local_now=timezone.now()) + + assert "сделать уборку" in tasks + + @pytest.mark.django_db def test_build_pet_notification_block_contains_pet_name_and_tasks(user, pet): text = build_pet_notification_block(pet, user) diff --git a/users/services.py b/users/services.py index 96cfa61..e88e29c 100644 --- a/users/services.py +++ b/users/services.py @@ -15,9 +15,8 @@ from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from config.url_services import build_absolute_url - -from pets.models import Event, Pet -from pets.services import get_next_repeat_datetime, get_pet_shedding_until, pet_can_handle +from pets.models import CareRule, Event, Pet +from pets.services import get_next_repeat_datetime, get_pet_shedding_until, get_upcoming_pet_task_items, pet_can_handle from users.timezone_services import get_user_local_now from .models import CustomUser @@ -351,46 +350,37 @@ def get_pet_tasks_for_today( today = local_now.date() user_tz = local_now.tzinfo - event_type_map = { + task_labels = { Event.EventType.FEEDING: "покормить", Event.EventType.CLEANING: "сделать уборку", Event.EventType.MEASUREMENT: "измерить и взвесить", } - for event_type, task_label in event_type_map.items(): - last_event = ( - pet.events.filter( - event_type=event_type, - repeat_after_days__isnull=False, - ) - .order_by("-event_datetime", "-pk") - .first() - ) - - if not last_event: - continue - - next_dt = get_next_repeat_datetime(last_event) - if not next_dt: - continue - - local_next_dt = next_dt.astimezone(user_tz) - if local_next_dt.date() == today: - tasks.append(task_label) - - custom_events = pet.events.filter( - event_type=Event.EventType.CUSTOM, - ).order_by("-event_datetime") - - for event in custom_events: - next_dt = get_next_repeat_datetime(event) - if not next_dt: - continue - - local_next_dt = next_dt.astimezone(user_tz) + for task in get_upcoming_pet_task_items(pet): + local_next_dt = task.next_datetime.astimezone(user_tz) if local_next_dt.date() == today: - event_name = event.title.strip() if event.title else "другое событие" - tasks.append(event_name) + tasks.append(task_labels.get(task.event_type_slug, task.event_type_name.lower())) + + custom_care_rule_exists = CareRule.objects.filter( + pet=pet, + event_type__slug=Event.EventType.CUSTOM, + is_active=True, + ).exists() + + if not custom_care_rule_exists: + custom_events = pet.events.filter( + event_type=Event.EventType.CUSTOM, + ).order_by("-event_datetime") + + for event in custom_events: + next_dt = get_next_repeat_datetime(event) + if not next_dt: + continue + + local_next_dt = next_dt.astimezone(user_tz) + if local_next_dt.date() == today: + event_name = event.title.strip() if event.title else "другое событие" + tasks.append(event_name) return tasks