diff --git a/docs/event_architecture_audit.md b/docs/event_architecture_audit.md deleted file mode 100644 index f67d9cc..0000000 --- a/docs/event_architecture_audit.md +++ /dev/null @@ -1,366 +0,0 @@ -# Архитектура событий ухода - -Документ фиксирует безопасный переход от текущей модели `Event` к будущей схеме ухода: - -- `Event` - факт произошедшего действия или наблюдения; -- `CareRule` - правило регулярности ухода; -- `PlannedEvent` - конкретная будущая задача; -- `PetCondition` - активное состояние питомца во времени; -- `EventType` - справочник системных и пользовательских типов событий. - -Главная цель перехода: сохранить уже внесённые данные животных, историю событий и актуальные статусы при будущих миграциях. - -## Текущее состояние - -Сейчас `Event` совмещает несколько ролей: - -- хранит факт события через `event_datetime`; -- хранит правило повторения через `repeat_after_days`; -- хранит временное ограничение контакта через `no_handling_days`; -- обновляет текущие `Pet.weight_grams` и `Pet.length_cm` для измерений; -- частично заменяет активное состояние, например линьку. - -Текущие типы событий: - -- `feeding`; -- `measurement`; -- `cleaning`; -- `shedding`; -- `custom`. - -Текущие производные данные: - -- актуальные вес и длина питомца синхронизируются с последними измерениями по каждому полю; -- запрет контакта считается до самого позднего активного срока среди событий с `no_handling_days`; -- линька считается активной, если есть активное событие `shedding` с неистёкшим сроком; -- ближайшие повторяющиеся задачи считаются от последнего фактического события с `repeat_after_days`; -- Telegram-уведомления используют текущую сервисную логику повторяющихся задач и статусов. - -## Данные, которые нельзя потерять - -При любых изменениях модели нужно сохранить: - -- питомцев и их владельцев; -- текущий статус питомца: `active`, `archived`, `deceased`, `rehomed`; -- тип животного, вид, морфу, пол, даты рождения и начала владения; -- текущие вес и длину; -- фото питомца и путь к файлу; -- заметки по кормлению и общий комментарий; -- публичность карточки; -- все события ухода; -- тип события, дату и время события; -- название и комментарий пользовательского события; -- вес и длину в событиях измерений; -- старые интервалы `repeat_after_days`; -- старые ограничения `no_handling_days`; -- юридические согласия, связанные с публичностью и уведомлениями. - -## Целевая схема - -### `Event` - -После миграции `Event` должен хранить только факт: - -- `pet`; -- `owner`; -- `event_type`; -- `happened_at`; -- `ended_at`, если событие имеет длительность; -- `title`; -- `comment`; -- `weight_grams`; -- `length_cm`; -- `related_planned_event`; -- служебные даты создания и обновления. - -Старое поле `event_datetime` нельзя удалять сразу. Переход должен идти через добавление нового поля, перенос данных и период совместимости. - -### `EventType` - -`EventType` нужен как справочник системных и пользовательских событий. - -Пользовательский тип события должен поддерживать те же сценарии, что и системный: разовый факт, запланированное действие и регулярное правило ухода, если для него включены соответствующие флаги. - -Минимальные поля: - -- `slug`; -- `name`; -- `is_system`; -- `owner`, если тип пользовательский; -- `can_be_recurring`; -- `can_be_planned`; -- `affects_status`; -- `allowed_animal_types`; -- `is_active`. - -Первые системные типы должны соответствовать текущим значениям `Event.EventType`, чтобы data migration была прямой и проверяемой. - -### `CareRule` - -`CareRule` хранит правило регулярности, а не факт. - -Минимальные поля: - -- `pet`; -- `event_type`; -- `interval_value`; -- `interval_unit`; -- `calculate_from`; -- `start_date`; -- `is_active`; -- `created_by`; -- `created_at`; -- `updated_at`. - -Варианты `calculate_from`: - -- `last_actual_event` - следующая дата считается от последнего фактического события; -- `last_planned_date` - следующая дата считается от предыдущей плановой даты; -- `fixed_schedule` - фиксированный календарный режим. - -Для кормления по умолчанию нужен `last_actual_event`. - -### `PlannedEvent` - -`PlannedEvent` хранит конкретное будущее действие. - -Минимальные поля: - -- `pet`; -- `event_type`; -- `scheduled_at`; -- `status`; -- `care_rule`; -- `actual_event`; -- `title`; -- `comment`; -- `created_by`; -- `created_at`; -- `updated_at`. - -Статусы: - -- `planned`; -- `done`; -- `skipped`; -- `cancelled`; - -Просроченность лучше не хранить как отдельный постоянный статус на первом этапе. Её можно вычислять как `status=planned` и `scheduled_at < now`. - -В интерфейсе просроченные задачи должны показываться как приоритетные и визуально выделяться красным цветом. - -Для одной пары `pet + event_type + care_rule` должна быть только одна активная ближайшая задача. - -### `PetCondition` - -`PetCondition` хранит состояние, которое длится во времени. - -Минимальные поля: - -- `pet`; -- `condition_type`; -- `started_at`; -- `ended_at`; -- `source_event`; -- `end_event`; -- `comment`; -- `created_at`; -- `updated_at`. - -Первые типы состояний: - -- `no_handling`; -- `shedding`; - -Позже: - -- `treatment`; -- `quarantine`; -- `brumation`; -- `incubation`; -- `recovery`; - -## Маппинг текущих данных - -### `event_datetime` - -Текущее значение переносится в `happened_at`. - -Правило: - -```text -Event.happened_at = Event.event_datetime -``` - -### `repeat_after_days` - -Для каждого питомца и типа события нужно найти последнее событие с заполненным `repeat_after_days`. - -Из него создаётся `CareRule`: - -```text -CareRule.pet = event.pet -CareRule.event_type = event.event_type -CareRule.interval_value = event.repeat_after_days -CareRule.interval_unit = days -CareRule.calculate_from = last_actual_event -CareRule.is_active = true -``` - -После создания `CareRule` можно создать ближайший `PlannedEvent`: - -```text -PlannedEvent.scheduled_at = last_actual_event.happened_at + interval -PlannedEvent.status = planned -``` - -Важно: будущий `PlannedEvent` не является фактом. `Event` создаётся только тогда, когда пользователь реально выполняет действие. - -### `no_handling_days` - -Каждое событие с `no_handling_days` может породить `PetCondition`. - -Правило: - -```text -PetCondition.condition_type = no_handling -PetCondition.started_at = event.event_datetime -PetCondition.ended_at = event.event_datetime + no_handling_days -PetCondition.source_event = event -``` - -Для `shedding` дополнительно создаётся состояние: - -```text -PetCondition.condition_type = shedding -PetCondition.started_at = event.event_datetime -PetCondition.ended_at = event.event_datetime + no_handling_days -PetCondition.source_event = event -``` - -Это промежуточная схема для миграции старых данных. Позже линьку нужно перевести на полноценный сценарий "началась" / "закончилась". - -### `custom` - -Текущие пользовательские события должны остаться фактами. - -Если у `custom` заполнен `repeat_after_days`, нужно создать пользовательский или системно-привязанный `EventType`, который допускает повторение, и соответствующий `CareRule`. - -## Этапы миграции - -### Этап 1. Подготовка без изменения схемы - -Уже сделано: - -- тестами зафиксирована текущая логика статусов; -- исправлен расчёт активного запрета контакта; -- исправлена синхронизация актуальных измерений при внесении старых событий; -- добавлена команда `recalculate_pet_statuses`. - -### Этап 2. Справочник `EventType` - -Добавить модель `EventType`. - -Data migration: - -- создать системные типы для текущих `feeding`, `measurement`, `cleaning`, `shedding`, `custom`; -- добавить в `Event` nullable-ссылку на новый `EventType`; -- заполнить ссылку по старому строковому `event_type`; -- код временно должен уметь читать и старое поле, и новую связь. - -Старое поле `event_type` не удалять. - -### Этап 3. `CareRule` - -Добавить `CareRule`. - -Data migration: - -- пройти по событиям с `repeat_after_days`; -- для каждой пары `pet + event_type` взять последнее правило; -- создать активный `CareRule`; -- старое поле `repeat_after_days` не удалять. - -После миграции: - -- формы настройки ухода должны писать в `CareRule`; -- старые события остаются историей; -- команда пересчёта должна уметь проверять соответствие `CareRule` старым данным. - -### Этап 4. `PlannedEvent` - -Добавить `PlannedEvent`. - -Data migration: - -- для каждого активного `CareRule` создать ближайшую плановую задачу; -- не создавать фактические `Event` на будущее; -- не создавать дубли активных задач. - -После миграции: - -- профиль и карточка питомца должны брать ближайшие дела из `PlannedEvent`; -- просроченные задачи показывать как приоритетные и выделять красным цветом; -- Telegram и будущие email-уведомления должны читать `PlannedEvent`. - -### Этап 5. `PetCondition` - -Добавить `PetCondition`. - -Data migration: - -- события с `no_handling_days` перенести в состояния `no_handling`; -- события `shedding` с `no_handling_days` перенести в состояния `shedding`; -- текущие сервисы статуса временно должны сравнивать старый и новый расчёт. - -После проверки: - -- карточка питомца читает активные состояния; -- команда пересчёта проверяет состояния и может восстановить их из истории; -- старый расчёт через `no_handling_days` остаётся fallback до полного перехода. - -### Этап 6. Переименование времени события - -Добавить `happened_at`, nullable. - -Data migration: - -```text -happened_at = event_datetime -``` - -Потом: - -- код читает `happened_at`; -- `event_datetime` остаётся fallback; -- удаление старого поля возможно только отдельной поздней миграцией после стабильного релиза. - -## Правила совместимости - -- Не удалять старые поля в той же миграции, где добавлены новые. -- Не менять смысл `Event` до завершения data migration. -- Не создавать будущие события как факты. -- Не пересчитывать production-данные без backup. -- Любая команда изменения данных должна иметь режим предпросмотра. -- Любой автоматический пересчёт должен быть покрыт тестами на старых данных. -- Все новые модели должны иметь индексы по владельцу, питомцу, типу и датам, если они участвуют в списках или уведомлениях. - -## Что проверять после каждого этапа - -- Количество питомцев не изменилось. -- Количество событий не изменилось неожиданно. -- У каждого события сохранились владелец, питомец, тип, дата, комментарий. -- Актуальные вес и длина совпадают с последними измерениями. -- Активный запрет контакта совпадает со старым расчётом. -- Активная линька совпадает со старым расчётом. -- Ближайшие повторяющиеся задачи совпадают со старым расчётом. -- Telegram-уведомления не потеряли задачи. -- Публичные карточки не раскрыли приватные данные. - -## Не делать в первом переходе - -- Не переносить магазин, размножение и передачу животных в эту же ветку. -- Не добавлять видовые профили ухода одновременно с `CareRule`. -- Не удалять `repeat_after_days`, `no_handling_days`, `event_datetime`. -- Не делать UI для всех будущих сущностей до завершения data migration. -- Не включать автоматический `--apply` в CI/CD. diff --git a/pets/admin.py b/pets/admin.py index cc4d673..e1b58bb 100644 --- a/pets/admin.py +++ b/pets/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Event, Pet +from .models import AnimalTypeEventRule, Event, EventType, Pet, PetEventSetting @admin.register(Pet) @@ -87,6 +87,113 @@ class PetAdmin(admin.ModelAdmin): ) +@admin.register(EventType) +class EventTypeAdmin(admin.ModelAdmin): + """Настройки отображения справочника типов событий.""" + + list_display = ( + "id", + "slug", + "name", + "is_system", + "owner", + "can_be_recurring", + "can_be_planned", + "affects_status", + "is_active", + ) + list_filter = ( + "is_system", + "can_be_recurring", + "can_be_planned", + "affects_status", + "is_active", + "created_at", + ) + search_fields = ( + "slug", + "name", + "owner__email", + ) + autocomplete_fields = ("owner",) + readonly_fields = ("created_at", "updated_at") + list_select_related = ("owner",) + ordering = ("name",) + + +@admin.register(AnimalTypeEventRule) +class AnimalTypeEventRuleAdmin(admin.ModelAdmin): + """Настройки отображения системных правил событий по типам животных.""" + + list_display = ( + "id", + "animal_type", + "event_type", + "is_available", + "is_default_tracked", + "show_quick_action_by_default", + "affects_status", + "handling_policy", + "show_no_handling_field", + "shedding_policy", + "sort_order", + "is_active", + ) + list_filter = ( + "animal_type", + "event_type", + "is_available", + "is_default_tracked", + "show_quick_action_by_default", + "affects_status", + "handling_policy", + "show_no_handling_field", + "shedding_policy", + "is_active", + ) + search_fields = ( + "event_type__slug", + "event_type__name", + ) + autocomplete_fields = ("event_type",) + readonly_fields = ("created_at", "updated_at") + list_select_related = ("event_type",) + ordering = ("animal_type", "sort_order", "event_type__name") + + +@admin.register(PetEventSetting) +class PetEventSettingAdmin(admin.ModelAdmin): + """Настройки отображения отслеживаемых событий питомцев.""" + + list_display = ( + "id", + "pet", + "event_type", + "is_tracked", + "show_quick_action", + "source", + "created_at", + ) + list_filter = ( + "event_type", + "is_tracked", + "show_quick_action", + "source", + "created_at", + ) + search_fields = ( + "pet__name", + "pet__species_name", + "pet__owner__email", + "event_type__slug", + "event_type__name", + ) + autocomplete_fields = ("pet", "event_type") + readonly_fields = ("created_at", "updated_at") + list_select_related = ("pet", "event_type", "pet__owner") + ordering = ("pet__name", "event_type__name") + + @admin.register(Event) class EventAdmin(admin.ModelAdmin): """Настройки отображения модели события в админке.""" @@ -95,6 +202,7 @@ class EventAdmin(admin.ModelAdmin): "id", "pet", "event_type", + "event_type_ref", "event_datetime", "owner", "repeat_after_days", @@ -103,6 +211,7 @@ class EventAdmin(admin.ModelAdmin): ) list_filter = ( "event_type", + "event_type_ref", "event_datetime", "created_at", ) @@ -112,10 +221,12 @@ class EventAdmin(admin.ModelAdmin): "owner__email", "title", "comment", + "event_type_ref__slug", + "event_type_ref__name", ) - autocomplete_fields = ("owner", "pet") + autocomplete_fields = ("owner", "pet", "event_type_ref") readonly_fields = ("created_at",) - list_select_related = ("owner", "pet") + list_select_related = ("owner", "pet", "event_type_ref") ordering = ("-event_datetime",) fieldsets = ( @@ -126,6 +237,7 @@ class EventAdmin(admin.ModelAdmin): "owner", "pet", "event_type", + "event_type_ref", "title", "event_datetime", "comment", diff --git a/pets/migrations/0010_eventtype_event_event_type_ref.py b/pets/migrations/0010_eventtype_event_event_type_ref.py new file mode 100644 index 0000000..fb77a26 --- /dev/null +++ b/pets/migrations/0010_eventtype_event_event_type_ref.py @@ -0,0 +1,143 @@ +# Generated by Django 5.2.13 on 2026-07-28 09:47 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +SYSTEM_EVENT_TYPES = ( + { + "slug": "feeding", + "name": "Кормление", + "can_be_recurring": True, + "can_be_planned": True, + "affects_status": True, + }, + { + "slug": "measurement", + "name": "Измерение", + "can_be_recurring": True, + "can_be_planned": True, + "affects_status": True, + }, + { + "slug": "cleaning", + "name": "Уборка", + "can_be_recurring": True, + "can_be_planned": True, + "affects_status": False, + }, + { + "slug": "shedding", + "name": "Линька", + "can_be_recurring": False, + "can_be_planned": False, + "affects_status": True, + }, + { + "slug": "custom", + "name": "Другое", + "can_be_recurring": True, + "can_be_planned": True, + "affects_status": False, + }, +) + + +def seed_event_types_and_link_events(apps, schema_editor): + EventType = apps.get_model("pets", "EventType") + Event = apps.get_model("pets", "Event") + + event_types_by_slug = {} + for event_type_data in SYSTEM_EVENT_TYPES: + slug = event_type_data["slug"] + event_type, _created = EventType.objects.update_or_create( + slug=slug, + defaults={ + "name": event_type_data["name"], + "is_system": True, + "owner": None, + "can_be_recurring": event_type_data["can_be_recurring"], + "can_be_planned": event_type_data["can_be_planned"], + "affects_status": event_type_data["affects_status"], + "allowed_animal_types": [], + "is_active": True, + }, + ) + event_types_by_slug[slug] = event_type + + for slug, event_type in event_types_by_slug.items(): + Event.objects.filter(event_type=slug, event_type_ref__isnull=True).update(event_type_ref=event_type) + + +def unlink_events_and_remove_seeded_event_types(apps, schema_editor): + EventType = apps.get_model("pets", "EventType") + Event = apps.get_model("pets", "Event") + + slugs = [event_type_data["slug"] for event_type_data in SYSTEM_EVENT_TYPES] + Event.objects.filter(event_type_ref__slug__in=slugs).update(event_type_ref=None) + EventType.objects.filter(slug__in=slugs, is_system=True, owner__isnull=True).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("pets", "0009_alter_pet_is_public"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="EventType", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slug", models.SlugField(unique=True, verbose_name="Slug")), + ("name", models.CharField(max_length=100, verbose_name="Название")), + ("is_system", models.BooleanField(default=False, verbose_name="Системный тип")), + ("can_be_recurring", models.BooleanField(default=False, verbose_name="Можно повторять")), + ("can_be_planned", models.BooleanField(default=False, verbose_name="Можно планировать")), + ("affects_status", models.BooleanField(default=False, verbose_name="Влияет на статус")), + ( + "allowed_animal_types", + models.JSONField( + blank=True, + default=list, + help_text="Список slug-ов типов животных. Пустой список означает без ограничений.", + 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="Дата обновления")), + ( + "owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="event_types", + to=settings.AUTH_USER_MODEL, + verbose_name="Владелец", + ), + ), + ], + options={ + "verbose_name": "тип события", + "verbose_name_plural": "типы событий", + "ordering": ["name"], + }, + ), + migrations.AddField( + model_name="event", + name="event_type_ref", + field=models.ForeignKey( + blank=True, + help_text="Переходное поле для будущей архитектуры событий. Старое поле event_type остаётся рабочим.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="events", + to="pets.eventtype", + verbose_name="Тип события из справочника", + ), + ), + migrations.RunPython(seed_event_types_and_link_events, unlink_events_and_remove_seeded_event_types), + ] diff --git a/pets/migrations/0011_animaltypeeventrule_peteventsetting.py b/pets/migrations/0011_animaltypeeventrule_peteventsetting.py new file mode 100644 index 0000000..94bd023 --- /dev/null +++ b/pets/migrations/0011_animaltypeeventrule_peteventsetting.py @@ -0,0 +1,321 @@ +# Generated by Django 5.2.13 on 2026-07-28 11:26 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + +ANIMAL_TYPES = ( + "snake", + "lizard", + "turtle", + "frog", + "spider", + "scorpion", + "other", +) + +SYSTEM_EVENT_TYPE_SLUGS = ( + "feeding", + "measurement", + "cleaning", + "shedding", + "custom", +) + +EVENT_TYPE_SORT_ORDER = { + "feeding": 10, + "shedding": 20, + "cleaning": 30, + "measurement": 40, + "custom": 50, +} + + +def get_rule_defaults(animal_type, event_type_slug): + defaults = { + "is_available": True, + "is_default_tracked": True, + "show_quick_action_by_default": True, + "affects_status": False, + "handling_policy": "none", + "default_no_handling_days": None, + "show_no_handling_field": "optional", + "shedding_policy": "none", + "sort_order": EVENT_TYPE_SORT_ORDER[event_type_slug], + "is_active": True, + } + + if event_type_slug == "feeding": + if animal_type == "snake": + defaults.update( + { + "affects_status": True, + "handling_policy": "recommended_no_handling", + "default_no_handling_days": 2, + "show_no_handling_field": "recommended", + } + ) + else: + defaults.update( + { + "handling_policy": "optional_no_handling", + "show_no_handling_field": "optional", + } + ) + elif event_type_slug == "measurement": + defaults["affects_status"] = True + defaults["show_no_handling_field"] = "hidden" + elif event_type_slug == "cleaning": + defaults["show_no_handling_field"] = "hidden" + elif event_type_slug == "shedding": + defaults["affects_status"] = True + if animal_type == "snake": + defaults.update( + { + "handling_policy": "recommended_no_handling", + "default_no_handling_days": 2, + "show_no_handling_field": "recommended", + "shedding_policy": "handling_sensitive", + } + ) + elif animal_type in {"spider", "scorpion"}: + defaults.update( + { + "handling_policy": "critical_no_disturb", + "show_no_handling_field": "recommended", + "shedding_policy": "critical", + } + ) + elif animal_type == "lizard": + defaults.update( + { + "show_no_handling_field": "hidden", + "shedding_policy": "informational", + } + ) + else: + defaults.update( + { + "show_no_handling_field": "optional", + "shedding_policy": "informational", + } + ) + elif event_type_slug == "custom": + defaults.update( + { + "handling_policy": "optional_no_handling", + "show_no_handling_field": "optional", + } + ) + + return defaults + + +def seed_animal_rules_and_pet_settings(apps, schema_editor): + AnimalTypeEventRule = apps.get_model("pets", "AnimalTypeEventRule") + EventType = apps.get_model("pets", "EventType") + Pet = apps.get_model("pets", "Pet") + PetEventSetting = apps.get_model("pets", "PetEventSetting") + + event_types_by_slug = { + event_type.slug: event_type + for event_type in EventType.objects.filter(slug__in=SYSTEM_EVENT_TYPE_SLUGS, is_system=True) + } + + for animal_type in ANIMAL_TYPES: + for event_type_slug in SYSTEM_EVENT_TYPE_SLUGS: + event_type = event_types_by_slug.get(event_type_slug) + if not event_type: + continue + + AnimalTypeEventRule.objects.update_or_create( + animal_type=animal_type, + event_type=event_type, + defaults=get_rule_defaults(animal_type, event_type_slug), + ) + + for pet in Pet.objects.all().iterator(): + for event_type_slug in SYSTEM_EVENT_TYPE_SLUGS: + event_type = event_types_by_slug.get(event_type_slug) + if not event_type: + continue + + PetEventSetting.objects.update_or_create( + pet=pet, + event_type=event_type, + defaults={ + "is_tracked": True, + "show_quick_action": True, + "source": "default", + }, + ) + + +def remove_seeded_animal_rules_and_pet_settings(apps, schema_editor): + AnimalTypeEventRule = apps.get_model("pets", "AnimalTypeEventRule") + EventType = apps.get_model("pets", "EventType") + PetEventSetting = apps.get_model("pets", "PetEventSetting") + + event_type_ids = EventType.objects.filter(slug__in=SYSTEM_EVENT_TYPE_SLUGS, is_system=True).values_list( + "id", + flat=True, + ) + PetEventSetting.objects.filter(event_type_id__in=event_type_ids, source="default").delete() + AnimalTypeEventRule.objects.filter(animal_type__in=ANIMAL_TYPES, event_type_id__in=event_type_ids).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("pets", "0010_eventtype_event_event_type_ref"), + ] + + operations = [ + migrations.CreateModel( + name="AnimalTypeEventRule", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "animal_type", + models.CharField( + choices=[ + ("snake", "Змея"), + ("lizard", "Ящерица"), + ("turtle", "Черепаха"), + ("frog", "Лягушка"), + ("spider", "Паук"), + ("scorpion", "Скорпион"), + ("other", "Иное"), + ], + max_length=20, + verbose_name="Тип животного", + ), + ), + ("is_available", models.BooleanField(default=True, verbose_name="Доступно")), + ("is_default_tracked", models.BooleanField(default=True, verbose_name="Отслеживать по умолчанию")), + ( + "show_quick_action_by_default", + models.BooleanField(default=True, verbose_name="Показывать быструю кнопку по умолчанию"), + ), + ("affects_status", models.BooleanField(default=False, verbose_name="Влияет на статус")), + ( + "handling_policy", + models.CharField( + choices=[ + ("none", "Нет"), + ("optional_no_handling", "Запрет контакта по желанию"), + ("recommended_no_handling", "Запрет контакта рекомендован"), + ("critical_no_disturb", "Критично не тревожить"), + ], + default="none", + max_length=30, + verbose_name="Правило контакта", + ), + ), + ( + "default_no_handling_days", + models.PositiveIntegerField( + blank=True, + null=True, + validators=[django.core.validators.MaxValueValidator(365)], + verbose_name="Запрет контакта по умолчанию (дней)", + ), + ), + ( + "show_no_handling_field", + models.CharField( + choices=[ + ("hidden", "Скрыто"), + ("optional", "Необязательно"), + ("recommended", "Рекомендовано"), + ("required", "Обязательно"), + ], + default="optional", + max_length=20, + verbose_name="Поле запрета контакта", + ), + ), + ( + "shedding_policy", + models.CharField( + choices=[ + ("none", "Нет"), + ("informational", "Информационно"), + ("handling_sensitive", "Чувствительно к контакту"), + ("critical", "Критично"), + ], + default="none", + max_length=30, + verbose_name="Правило линьки", + ), + ), + ("sort_order", models.PositiveIntegerField(default=0, 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="Дата обновления")), + ( + "event_type", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="animal_type_rules", + to="pets.eventtype", + verbose_name="Тип события", + ), + ), + ], + options={ + "verbose_name": "правило события для типа животного", + "verbose_name_plural": "правила событий для типов животных", + "ordering": ["animal_type", "sort_order", "event_type__name"], + "constraints": [ + models.UniqueConstraint(fields=("animal_type", "event_type"), name="unique_animal_type_event_rule") + ], + }, + ), + migrations.CreateModel( + name="PetEventSetting", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("is_tracked", models.BooleanField(default=True, verbose_name="Отслеживается")), + ("show_quick_action", models.BooleanField(default=True, verbose_name="Показывать быструю кнопку")), + ( + "source", + models.CharField( + choices=[("default", "По умолчанию"), ("user", "Пользовательская")], + default="default", + max_length=20, + verbose_name="Источник настройки", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Дата обновления")), + ( + "event_type", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="pet_settings", + to="pets.eventtype", + verbose_name="Тип события", + ), + ), + ( + "pet", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="event_settings", + to="pets.pet", + verbose_name="Питомец", + ), + ), + ], + options={ + "verbose_name": "настройка события питомца", + "verbose_name_plural": "настройки событий питомцев", + "ordering": ["pet__name", "event_type__name"], + "constraints": [ + models.UniqueConstraint(fields=("pet", "event_type"), name="unique_pet_event_setting") + ], + }, + ), + migrations.RunPython(seed_animal_rules_and_pet_settings, remove_seeded_animal_rules_and_pet_settings), + ] diff --git a/pets/models.py b/pets/models.py index 36d226b..61dad47 100644 --- a/pets/models.py +++ b/pets/models.py @@ -174,6 +174,234 @@ class Meta: verbose_name_plural = "питомцы" +class EventType(models.Model): + """Справочник системных и пользовательских типов событий ухода.""" + + slug = models.SlugField( + max_length=50, + unique=True, + verbose_name="Slug", + ) + name = models.CharField( + max_length=100, + verbose_name="Название", + ) + is_system = models.BooleanField( + default=False, + verbose_name="Системный тип", + ) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="event_types", + verbose_name="Владелец", + ) + can_be_recurring = models.BooleanField( + default=False, + verbose_name="Можно повторять", + ) + can_be_planned = models.BooleanField( + default=False, + verbose_name="Можно планировать", + ) + affects_status = models.BooleanField( + default=False, + verbose_name="Влияет на статус", + ) + allowed_animal_types = models.JSONField( + blank=True, + default=list, + verbose_name="Доступные типы животных", + help_text="Список slug-ов типов животных. Пустой список означает без ограничений.", + ) + 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="Дата обновления", + ) + + def __str__(self) -> str: + """Возвращает читаемое название типа события.""" + return self.name + + class Meta: + ordering = ["name"] + verbose_name = "тип события" + verbose_name_plural = "типы событий" + + +class AnimalTypeEventRule(models.Model): + """Системное правило доступности события для типа животного.""" + + class HandlingPolicy(models.TextChoices): + NONE = "none", "Нет" + OPTIONAL_NO_HANDLING = "optional_no_handling", "Запрет контакта по желанию" + RECOMMENDED_NO_HANDLING = "recommended_no_handling", "Запрет контакта рекомендован" + CRITICAL_NO_DISTURB = "critical_no_disturb", "Критично не тревожить" + + class NoHandlingFieldVisibility(models.TextChoices): + HIDDEN = "hidden", "Скрыто" + OPTIONAL = "optional", "Необязательно" + RECOMMENDED = "recommended", "Рекомендовано" + REQUIRED = "required", "Обязательно" + + class SheddingPolicy(models.TextChoices): + NONE = "none", "Нет" + INFORMATIONAL = "informational", "Информационно" + HANDLING_SENSITIVE = "handling_sensitive", "Чувствительно к контакту" + CRITICAL = "critical", "Критично" + + animal_type = models.CharField( + max_length=20, + choices=Pet.AnimalType.choices, + verbose_name="Тип животного", + ) + event_type = models.ForeignKey( + EventType, + on_delete=models.PROTECT, + related_name="animal_type_rules", + verbose_name="Тип события", + ) + is_available = models.BooleanField( + default=True, + verbose_name="Доступно", + ) + is_default_tracked = models.BooleanField( + default=True, + verbose_name="Отслеживать по умолчанию", + ) + show_quick_action_by_default = models.BooleanField( + default=True, + verbose_name="Показывать быструю кнопку по умолчанию", + ) + affects_status = models.BooleanField( + default=False, + verbose_name="Влияет на статус", + ) + handling_policy = models.CharField( + max_length=30, + choices=HandlingPolicy.choices, + default=HandlingPolicy.NONE, + verbose_name="Правило контакта", + ) + default_no_handling_days = models.PositiveIntegerField( + blank=True, + null=True, + verbose_name="Запрет контакта по умолчанию (дней)", + validators=[MaxValueValidator(365)], + ) + show_no_handling_field = models.CharField( + max_length=20, + choices=NoHandlingFieldVisibility.choices, + default=NoHandlingFieldVisibility.OPTIONAL, + verbose_name="Поле запрета контакта", + ) + shedding_policy = models.CharField( + max_length=30, + choices=SheddingPolicy.choices, + default=SheddingPolicy.NONE, + verbose_name="Правило линьки", + ) + sort_order = models.PositiveIntegerField( + default=0, + 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="Дата обновления", + ) + + def __str__(self) -> str: + """Возвращает читаемое представление системного правила.""" + return f"{self.get_animal_type_display()} - {self.event_type.name}" + + class Meta: + ordering = ["animal_type", "sort_order", "event_type__name"] + constraints = [ + models.UniqueConstraint( + fields=["animal_type", "event_type"], + name="unique_animal_type_event_rule", + ) + ] + verbose_name = "правило события для типа животного" + verbose_name_plural = "правила событий для типов животных" + + +class PetEventSetting(models.Model): + """Настройка отслеживания типа события для конкретного питомца.""" + + class Source(models.TextChoices): + DEFAULT = "default", "По умолчанию" + USER = "user", "Пользовательская" + + pet = models.ForeignKey( + Pet, + on_delete=models.CASCADE, + related_name="event_settings", + verbose_name="Питомец", + ) + event_type = models.ForeignKey( + EventType, + on_delete=models.PROTECT, + related_name="pet_settings", + verbose_name="Тип события", + ) + is_tracked = models.BooleanField( + default=True, + verbose_name="Отслеживается", + ) + show_quick_action = models.BooleanField( + default=True, + verbose_name="Показывать быструю кнопку", + ) + source = models.CharField( + max_length=20, + choices=Source.choices, + default=Source.DEFAULT, + verbose_name="Источник настройки", + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания", + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления", + ) + + def __str__(self) -> str: + """Возвращает читаемое представление настройки питомца.""" + return f"{self.pet.name} - {self.event_type.name}" + + class Meta: + ordering = ["pet__name", "event_type__name"] + constraints = [ + models.UniqueConstraint( + fields=["pet", "event_type"], + name="unique_pet_event_setting", + ) + ] + verbose_name = "настройка события питомца" + verbose_name_plural = "настройки событий питомцев" + + class Event(models.Model): """Модель события ухода или наблюдения, связанного с конкретным питомцем.""" @@ -204,6 +432,16 @@ class EventType(models.TextChoices): verbose_name="Тип события", ) + event_type_ref = models.ForeignKey( + "EventType", + blank=True, + null=True, + on_delete=models.PROTECT, + related_name="events", + verbose_name="Тип события из справочника", + help_text="Переходное поле для будущей архитектуры событий. Старое поле event_type остаётся рабочим.", + ) + title = models.CharField( max_length=150, blank=True, @@ -286,11 +524,21 @@ def save(self, *args, **kwargs): """Сохраняет событие и обновляет параметры питомца, если это событие измерения.""" self.full_clean() + self.sync_event_type_ref_from_legacy_field() super().save(*args, **kwargs) if self.event_type == self.EventType.MEASUREMENT: self.sync_pet_measurements() + def sync_event_type_ref_from_legacy_field(self) -> None: + """Заполняет переходную ссылку на справочник по старому строковому типу.""" + if self.event_type_ref_id or not self.event_type: + return + + event_type_ref = EventType.objects.filter(slug=self.event_type).first() + if event_type_ref: + self.event_type_ref = event_type_ref + def sync_pet_measurements(self) -> None: """Синхронизирует актуальные параметры питомца с последними измерениями.""" pet: Pet = self.pet diff --git a/pets/services.py b/pets/services.py index 10bd21a..ca49b0a 100644 --- a/pets/services.py +++ b/pets/services.py @@ -6,7 +6,56 @@ from django.utils import timezone -from .models import Event, Pet +from .models import AnimalTypeEventRule, Event, EventType, Pet, PetEventSetting + +EVENT_SETTINGS_SUBMITTED_FIELD = "event_settings_submitted" +EVENT_SETTING_TRACKED_FIELD_TEMPLATE = "event_setting_{slug}_is_tracked" +EVENT_SETTING_QUICK_ACTION_FIELD_TEMPLATE = "event_setting_{slug}_show_quick_action" +EVENT_SETTING_DISPLAY_NAMES = { + cast(str, Event.EventType.CUSTOM): "Свои события", +} +EVENT_DISABLED_WARNING = ( + "Это событие выключено в настройках питомца. " + "Вы всё ещё можете добавить его по прямой ссылке, " + "но быстрая кнопка в карточке питомца скрыта." +) +ANIMAL_TYPE_UNAVAILABLE_LABELS = { + Pet.AnimalType.SNAKE: "змей", + Pet.AnimalType.LIZARD: "ящериц", + Pet.AnimalType.TURTLE: "черепах", + Pet.AnimalType.FROG: "лягушек", + Pet.AnimalType.SPIDER: "пауков", + Pet.AnimalType.SCORPION: "скорпионов", + Pet.AnimalType.OTHER: "этого типа питомцев", +} + +LEGACY_QUICK_EVENT_ACTIONS: Sequence[dict[str, str]] = ( + { + "slug": cast(str, Event.EventType.FEEDING), + "label": "Кормление", + "button_class": "btn-ui btn-ui--primary", + }, + { + "slug": cast(str, Event.EventType.SHEDDING), + "label": "Линька", + "button_class": "btn-ui btn-ui--secondary", + }, + { + "slug": cast(str, Event.EventType.CLEANING), + "label": "Уборка", + "button_class": "btn-ui btn-ui--secondary", + }, + { + "slug": cast(str, Event.EventType.MEASUREMENT), + "label": "Измерение", + "button_class": "btn-ui btn-ui--ghost", + }, + { + "slug": cast(str, Event.EventType.CUSTOM), + "label": "Своё событие", + "button_class": "btn-ui btn-ui--ghost", + }, +) @dataclass(frozen=True) @@ -21,6 +70,14 @@ class PetStatusRecalculationResult: applied: bool +@dataclass(frozen=True) +class PetEventCreationAvailability: + """Хранит результат мягкой проверки создания события для питомца.""" + + warning: str = "" + error: str = "" + + def get_pet_age_display(birth_date: date | None) -> str: """Возвращает возраст питомца в сокращённом человекочитаемом формате.""" if not birth_date: @@ -258,6 +315,155 @@ def get_upcoming_pet_tasks(pet: Pet) -> list[str]: return [text for _, text in tasks] +def get_pet_quick_event_actions(pet: Pet) -> list[dict[str, str]]: + """Возвращает быстрые кнопки событий для питомца с безопасным fallback.""" + legacy_actions_by_slug = {action["slug"]: action for action in LEGACY_QUICK_EVENT_ACTIONS} + legacy_slugs = list(legacy_actions_by_slug) + + event_settings = pet.event_settings.select_related("event_type").filter( + event_type__slug__in=legacy_slugs, + ) + + if not event_settings.exists(): + return [dict(action) for action in LEGACY_QUICK_EVENT_ACTIONS] + + visible_slugs = { + setting.event_type.slug + for setting in event_settings + if setting.is_tracked and setting.show_quick_action and setting.event_type.is_active + } + + return [dict(action) for action in LEGACY_QUICK_EVENT_ACTIONS if action["slug"] in visible_slugs] + + +def get_pet_event_creation_availability(pet: Pet, event_type_slug: str) -> PetEventCreationAvailability: + """Проверяет, можно ли создать событие для выбранного питомца и типа события.""" + event_type = EventType.objects.filter( + slug=event_type_slug, + is_system=True, + is_active=True, + ).first() + + if not event_type: + return PetEventCreationAvailability() + + rule = AnimalTypeEventRule.objects.filter( + animal_type=pet.animal_type, + event_type=event_type, + is_active=True, + ).first() + + if rule and not rule.is_available: + animal_type_label = ANIMAL_TYPE_UNAVAILABLE_LABELS.get(pet.animal_type, "этого типа питомцев") + return PetEventCreationAvailability( + error=( + f"Это событие недоступно для {animal_type_label}. " + "Проверьте настройки событий питомца или правила событий в админке." + ) + ) + + setting = PetEventSetting.objects.filter( + pet=pet, + event_type=event_type, + ).first() + + if setting and not setting.is_tracked: + return PetEventCreationAvailability(warning=EVENT_DISABLED_WARNING) + + return PetEventCreationAvailability() + + +def get_pet_event_setting_rows(pet: Pet | None, data=None) -> list[dict[str, object]]: + """Возвращает строки настроек отслеживаемых событий для формы питомца.""" + legacy_slugs = [action["slug"] for action in LEGACY_QUICK_EVENT_ACTIONS] + event_types_by_slug = { + event_type.slug: event_type + for event_type in EventType.objects.filter( + slug__in=legacy_slugs, + is_system=True, + is_active=True, + ) + } + settings_by_slug: dict[str, PetEventSetting] = {} + + if pet and pet.pk: + settings_by_slug = { + setting.event_type.slug: setting + for setting in pet.event_settings.select_related("event_type").filter( + event_type__slug__in=legacy_slugs, + ) + } + + rows: list[dict[str, object]] = [] + use_posted_values = bool(data is not None and EVENT_SETTINGS_SUBMITTED_FIELD in data) + + for action in LEGACY_QUICK_EVENT_ACTIONS: + slug = action["slug"] + event_type = event_types_by_slug.get(slug) + + if not event_type: + continue + + tracked_field_name = EVENT_SETTING_TRACKED_FIELD_TEMPLATE.format(slug=slug) + quick_action_field_name = EVENT_SETTING_QUICK_ACTION_FIELD_TEMPLATE.format(slug=slug) + + if use_posted_values: + is_tracked = tracked_field_name in data + show_quick_action = is_tracked and quick_action_field_name in data + else: + setting = settings_by_slug.get(slug) + is_tracked = setting.is_tracked if setting else True + show_quick_action = setting.show_quick_action if setting else True + + rows.append( + { + "slug": slug, + "name": EVENT_SETTING_DISPLAY_NAMES.get(slug, event_type.name), + "is_tracked": is_tracked, + "show_quick_action": show_quick_action, + "tracked_field_name": tracked_field_name, + "quick_action_field_name": quick_action_field_name, + } + ) + + return rows + + +def save_pet_event_settings(pet: Pet, data) -> None: + """Сохраняет настройки отслеживаемых событий питомца из формы.""" + if EVENT_SETTINGS_SUBMITTED_FIELD not in data: + return + + legacy_slugs = [action["slug"] for action in LEGACY_QUICK_EVENT_ACTIONS] + event_types_by_slug = { + event_type.slug: event_type + for event_type in EventType.objects.filter( + slug__in=legacy_slugs, + is_system=True, + is_active=True, + ) + } + + for slug in legacy_slugs: + event_type = event_types_by_slug.get(slug) + + if not event_type: + continue + + is_tracked = EVENT_SETTING_TRACKED_FIELD_TEMPLATE.format(slug=slug) in data + show_quick_action = is_tracked and EVENT_SETTING_QUICK_ACTION_FIELD_TEMPLATE.format(slug=slug) in data + + PetEventSetting.objects.update_or_create( + pet=pet, + event_type=event_type, + defaults={ + "is_tracked": is_tracked, + "show_quick_action": show_quick_action, + "source": PetEventSetting.Source.USER, + }, + ) + + def get_repeat_comment(event: Event) -> str: """Формирует строку о следующем повторении события для показа в интерфейсе.""" next_dt = get_next_repeat_datetime(event) diff --git a/pets/templates/pets/event_form.html b/pets/templates/pets/event_form.html index 0d83f71..b15ac78 100644 --- a/pets/templates/pets/event_form.html +++ b/pets/templates/pets/event_form.html @@ -40,6 +40,14 @@

{% endfor %} {% endif %} + {% if event_creation_error and not form.non_field_errors %} +
{{ event_creation_error }}
+ {% endif %} + + {% if event_creation_warning %} +
{{ event_creation_warning }}
+ {% endif %} +
{% for field in form %}
@@ -61,7 +69,7 @@

- @@ -73,4 +81,4 @@

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/pets/templates/pets/includes/pet_cards_grid.html b/pets/templates/pets/includes/pet_cards_grid.html index 5d45ca7..4ad7658 100644 --- a/pets/templates/pets/includes/pet_cards_grid.html +++ b/pets/templates/pets/includes/pet_cards_grid.html @@ -16,7 +16,13 @@
-

{{ card.pet.name }}

+

+ {% if request.user.is_authenticated %} + {{ card.pet.name }} + {% else %} + {{ card.pet.name }} + {% endif %} +

{{ card.pet.get_animal_type_display }} • {{ card.pet.species_name }} @@ -83,4 +89,4 @@

{{ card.pet.name }}

{% endfor %} -
\ No newline at end of file + diff --git a/pets/templates/pets/pet_detail.html b/pets/templates/pets/pet_detail.html index a0e8b0b..c20bb4f 100644 --- a/pets/templates/pets/pet_detail.html +++ b/pets/templates/pets/pet_detail.html @@ -128,12 +128,11 @@

Добавить событие

- Кормление - Линька - Уборка - Измерение - Своё - событие + {% for action in quick_event_actions %} + + {{ action.label }} + + {% endfor %}
@@ -187,4 +186,4 @@

Последние события

{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/pets/templates/pets/pet_form.html b/pets/templates/pets/pet_form.html index 9e754c1..38a1ba4 100644 --- a/pets/templates/pets/pet_form.html +++ b/pets/templates/pets/pet_form.html @@ -46,6 +46,69 @@

{% endfor %} + {% if event_setting_rows %} +
+ + +

Отслеживаемые события

+
+

Выберите, какие события для своего питомца Вы хотите отслеживать:

+
    +
  • + Включить — если галочка стоит, Вы сможете добавлять события этого типа и получать напоминания по ним. +
  • +
  • + Быстрая кнопка — в карточке питомца появится кнопка быстрого добавления этого события. +
  • +
  • + Свои события — это все уникальные события, которые Вы добавляете и полностью настраиваете самостоятельно, например поход к ветеринару или смена UV-лампы. +
  • +
+
+ +
+ {% for row in event_setting_rows %} +
+
+
{{ row.name }}
+
+ +
+
+ + +
+ +
+ + +
+
+
+ {% endfor %} +
+
+ {% endif %} +
-{% endblock %} \ No newline at end of file +{% endblock %} + +{% block scripts %} +{{ block.super }} + +{% endblock %} diff --git a/pets/views.py b/pets/views.py index d2bfb91..c4a5e3b 100644 --- a/pets/views.py +++ b/pets/views.py @@ -6,6 +6,7 @@ from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView from legal.services import create_public_pet_card_acceptance +from users.timezone_services import get_user_local_now from .forms import ( CleaningEventForm, @@ -22,11 +23,15 @@ get_measurement_comment_lines, get_owner_display, get_pet_age_display, + get_pet_event_creation_availability, + get_pet_event_setting_rows, get_pet_no_handling_until, + get_pet_quick_event_actions, get_pet_shedding_until, get_upcoming_pet_tasks, pet_can_handle, pet_is_in_shedding, + save_pet_event_settings, ) @@ -141,6 +146,7 @@ def get_context_data(self, **kwargs): context["upcoming_tasks"] = get_upcoming_pet_tasks(pet) context["is_public_view"] = not self.can_edit_pet(pet) context["pet_event_rows"] = [build_event_row_context(event) for event in events] + context["quick_event_actions"] = get_pet_quick_event_actions(pet) context["no_handling_until"] = get_pet_no_handling_until(pet) context["shedding_until"] = get_pet_shedding_until(pet) return context @@ -189,8 +195,31 @@ def form_valid(self, form: PetForm) -> HttpResponse: return response +class PetEventSettingsMixin: + """Добавляет в форму питомца настройки отслеживаемых событий.""" + + request: HttpRequest + + def get_context_data(self, **kwargs): + """Передаёт в шаблон строки настроек событий питомца.""" + context = super().get_context_data(**kwargs) + data = self.request.POST if self.request.method == "POST" else None + context["event_setting_rows"] = get_pet_event_setting_rows( + getattr(self, "object", None), + data=data, + ) + return context + + def form_valid(self, form: PetForm) -> HttpResponse: + """Сохраняет настройки событий после успешного сохранения питомца.""" + response = super().form_valid(form) # type: ignore[misc] + save_pet_event_settings(self.object, self.request.POST) + return response + + class PetCreateView( LoginRequiredMixin, + PetEventSettingsMixin, PublicPetCardAcceptanceMixin, CreateView, ): @@ -209,6 +238,7 @@ def form_valid(self, form): class PetUpdateView( LoginRequiredMixin, PetEditPermissionMixin, + PetEventSettingsMixin, PublicPetCardAcceptanceMixin, UpdateView, ): @@ -338,10 +368,47 @@ def get_form_kwargs(self): kwargs["is_moderator"] = self.is_moderator() return kwargs + def get_selected_pet_for_availability_check(self) -> Pet | None: + """Возвращает выбранного питомца для мягкой проверки создания события.""" + pet_id = self.request.POST.get("pet") if self.request.method == "POST" else self.request.GET.get("pet") + + if not pet_id: + return None + + qs = Pet.objects.select_related("owner") + if not self.is_moderator(): + qs = qs.filter(owner=self.request.user) + + return qs.filter(pk=pet_id).first() + + def get_availability_for_pet(self, pet: Pet | None = None): + """Возвращает результат проверки доступности события для питомца.""" + selected_pet = pet or self.get_selected_pet_for_availability_check() + + if not selected_pet: + return None + + return get_pet_event_creation_availability(selected_pet, self.event_type) + + def get_context_data(self, **kwargs): + """Добавляет в форму мягкие предупреждения по настройкам события.""" + context = super().get_context_data(**kwargs) + availability = self.get_availability_for_pet() + + if availability: + context["event_creation_warning"] = availability.warning + context["event_creation_error"] = availability.error + + return context + 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") pet_id = self.request.GET.get("pet") if pet_id: initial["pet"] = pet_id @@ -353,6 +420,12 @@ def form_valid(self, form): Для модератора владельцем события становится владелец выбранного питомца. Для обычного пользователя — сам текущий пользователь. """ + availability = self.get_availability_for_pet(form.cleaned_data["pet"]) + + if availability and availability.error: + form.add_error(None, availability.error) + return self.form_invalid(form) + 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) diff --git a/static/css/base.css b/static/css/base.css index 8b03c53..5fd399b 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -260,6 +260,17 @@ img { margin-bottom: var(--space-2); } +.pet-card__title a { + color: var(--color-text); + text-decoration: none; +} + +.pet-card__title a:hover, +.pet-card__title a:focus-visible { + color: var(--color-primary-hover); + text-decoration: none; +} + .pet-card__meta { font-size: var(--font-size-sm); color: var(--color-text-muted); diff --git a/tests/test_pets_migrations.py b/tests/test_pets_migrations.py new file mode 100644 index 0000000..ddfd86b --- /dev/null +++ b/tests/test_pets_migrations.py @@ -0,0 +1,151 @@ +import pytest +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.utils import timezone + + +@pytest.mark.django_db(transaction=True) +def test_event_type_catalog_migration_links_existing_events(): + migrate_from = [ + ("users", "0007_customuser_email_confirmed_at_and_more"), + ("pets", "0009_alter_pet_is_public"), + ] + migrate_to = [ + ("users", "0007_customuser_email_confirmed_at_and_more"), + ("pets", "0010_eventtype_event_event_type_ref"), + ] + + 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") + + user = User.objects.create( + email="migration-user@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=timezone.now(), + repeat_after_days=7, + no_handling_days=2, + comment="Историческое кормление", + ) + event_count_before_migration = Event.objects.count() + + executor = MigrationExecutor(connection) + executor.migrate(migrate_to) + new_apps = executor.loader.project_state(migrate_to).apps + + EventType = new_apps.get_model("pets", "EventType") + Event = new_apps.get_model("pets", "Event") + event = Event.objects.get() + + assert Event.objects.count() == event_count_before_migration + assert event.event_type == "feeding" + assert event.repeat_after_days == 7 + assert event.no_handling_days == 2 + assert event.event_type_ref_id is not None + assert EventType.objects.get(pk=event.event_type_ref_id).slug == "feeding" + assert set(EventType.objects.values_list("slug", flat=True)) == { + "feeding", + "measurement", + "cleaning", + "shedding", + "custom", + } + + +@pytest.mark.django_db(transaction=True) +def test_event_rule_migration_creates_pet_settings_without_changing_events(): + migrate_from_base = [ + ("users", "0007_customuser_email_confirmed_at_and_more"), + ("pets", "0009_alter_pet_is_public"), + ] + migrate_from = [ + ("users", "0007_customuser_email_confirmed_at_and_more"), + ("pets", "0010_eventtype_event_event_type_ref"), + ] + migrate_to = [ + ("users", "0007_customuser_email_confirmed_at_and_more"), + ("pets", "0011_animaltypeeventrule_peteventsetting"), + ] + + executor = MigrationExecutor(connection) + executor.migrate(migrate_from_base) + + 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") + EventType = old_apps.get_model("pets", "EventType") + + user = User.objects.create( + email="event-settings-migration@example.com", + password="testpass123", + first_name="Катя", + time_zone="UTC", + ) + pet = Pet.objects.create( + owner=user, + name="Куку", + animal_type="snake", + species_name="Маисовый полоз", + ) + feeding_type = EventType.objects.get(slug="feeding") + Event.objects.create( + owner=user, + pet=pet, + event_type="feeding", + event_type_ref=feeding_type, + event_datetime=timezone.now(), + repeat_after_days=7, + no_handling_days=2, + comment="Историческое кормление", + ) + pet_count_before_migration = Pet.objects.count() + event_count_before_migration = Event.objects.count() + + executor = MigrationExecutor(connection) + executor.migrate(migrate_to) + new_apps = executor.loader.project_state(migrate_to).apps + + AnimalTypeEventRule = new_apps.get_model("pets", "AnimalTypeEventRule") + Event = new_apps.get_model("pets", "Event") + EventType = new_apps.get_model("pets", "EventType") + Pet = new_apps.get_model("pets", "Pet") + PetEventSetting = new_apps.get_model("pets", "PetEventSetting") + event = Event.objects.get() + + assert Pet.objects.count() == pet_count_before_migration + assert Event.objects.count() == event_count_before_migration + assert event.event_type == "feeding" + assert event.repeat_after_days == 7 + assert event.no_handling_days == 2 + assert AnimalTypeEventRule.objects.count() == 35 + assert PetEventSetting.objects.filter(pet_id=pet.id).count() == 5 + assert set(PetEventSetting.objects.filter(pet_id=pet.id).values_list("event_type__slug", flat=True)) == { + "feeding", + "measurement", + "cleaning", + "shedding", + "custom", + } + 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 diff --git a/tests/test_pets_models.py b/tests/test_pets_models.py index 1b29db6..36248dd 100644 --- a/tests/test_pets_models.py +++ b/tests/test_pets_models.py @@ -2,9 +2,18 @@ import pytest from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction from django.utils import timezone -from pets.models import Event, Pet +from pets.models import AnimalTypeEventRule, Event, EventType, Pet, PetEventSetting + +SYSTEM_EVENT_TYPE_SLUGS = { + Event.EventType.FEEDING, + Event.EventType.MEASUREMENT, + Event.EventType.CLEANING, + Event.EventType.SHEDDING, + Event.EventType.CUSTOM, +} @pytest.mark.django_db @@ -50,6 +59,78 @@ def test_custom_event_requires_title(user, pet): event.full_clean() +@pytest.mark.django_db +def test_system_event_types_are_seeded(): + event_types = EventType.objects.filter(slug__in=SYSTEM_EVENT_TYPE_SLUGS) + + assert event_types.count() == len(SYSTEM_EVENT_TYPE_SLUGS) + assert {event_type.slug for event_type in event_types} == SYSTEM_EVENT_TYPE_SLUGS + assert all(event_type.is_system for event_type in event_types) + assert all(event_type.owner_id is None for event_type in event_types) + + +@pytest.mark.django_db +def test_event_creation_keeps_legacy_type_and_sets_catalog_ref(user, pet): + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now(), + ) + + assert event.event_type == Event.EventType.FEEDING + assert event.event_type_ref is not None + assert event.event_type_ref.slug == Event.EventType.FEEDING + + +@pytest.mark.django_db +def test_system_animal_type_event_rules_are_seeded(): + expected_count = len(Pet.AnimalType.values) * len(SYSTEM_EVENT_TYPE_SLUGS) + + assert AnimalTypeEventRule.objects.count() == expected_count + + snake_feeding_rule = AnimalTypeEventRule.objects.get( + animal_type=Pet.AnimalType.SNAKE, + event_type__slug=Event.EventType.FEEDING, + ) + spider_shedding_rule = AnimalTypeEventRule.objects.get( + animal_type=Pet.AnimalType.SPIDER, + event_type__slug=Event.EventType.SHEDDING, + ) + + assert snake_feeding_rule.is_available is True + assert snake_feeding_rule.is_default_tracked is True + assert snake_feeding_rule.show_quick_action_by_default is True + assert snake_feeding_rule.handling_policy == AnimalTypeEventRule.HandlingPolicy.RECOMMENDED_NO_HANDLING + assert spider_shedding_rule.shedding_policy == AnimalTypeEventRule.SheddingPolicy.CRITICAL + + +@pytest.mark.django_db +def test_animal_type_event_rule_is_unique_per_animal_type_and_event_type(): + event_type = EventType.objects.get(slug=Event.EventType.FEEDING) + + with pytest.raises(IntegrityError), transaction.atomic(): + AnimalTypeEventRule.objects.create( + animal_type=Pet.AnimalType.SNAKE, + event_type=event_type, + ) + + +@pytest.mark.django_db +def test_pet_event_setting_is_unique_per_pet_and_event_type(pet): + event_type = EventType.objects.get(slug=Event.EventType.FEEDING) + PetEventSetting.objects.create( + pet=pet, + event_type=event_type, + ) + + with pytest.raises(IntegrityError), transaction.atomic(): + PetEventSetting.objects.create( + pet=pet, + event_type=event_type, + ) + + @pytest.mark.django_db def test_measurement_event_requires_weight_or_length(user, pet): event = Event( diff --git a/tests/test_pets_views.py b/tests/test_pets_views.py index 3d8ee40..c16d1bb 100644 --- a/tests/test_pets_views.py +++ b/tests/test_pets_views.py @@ -1,9 +1,20 @@ +from datetime import datetime +from datetime import timezone as dt_timezone + import pytest from django.urls import reverse from django.utils import timezone from legal.models import LegalDocumentVersion, UserLegalAcceptance -from pets.models import Event, Pet +from pets.models import AnimalTypeEventRule, Event, EventType, Pet, PetEventSetting + +SYSTEM_EVENT_SETTING_SLUGS = ( + "feeding", + "shedding", + "cleaning", + "measurement", + "custom", +) def build_pet_post_data( @@ -28,6 +39,35 @@ def build_pet_post_data( } +def build_pet_post_data_with_event_settings( + *, + name: str = "Новый", + is_public: bool = False, + tracked: dict[str, bool] | None = None, + quick_actions: dict[str, bool] | None = None, +) -> dict[str, object]: + """Возвращает данные формы питомца вместе с настройками событий.""" + tracked = tracked or {} + quick_actions = quick_actions or {} + data = build_pet_post_data( + name=name, + is_public=is_public, + ) + data["event_settings_submitted"] = "1" + + for slug in SYSTEM_EVENT_SETTING_SLUGS: + is_tracked = tracked.get(slug, True) + show_quick_action = quick_actions.get(slug, True) + + if is_tracked: + data[f"event_setting_{slug}_is_tracked"] = "on" + + if is_tracked and show_quick_action: + data[f"event_setting_{slug}_show_quick_action"] = "on" + + return data + + def create_published_public_pet_card_rules( *, version: str = "test-1.0", @@ -99,6 +139,205 @@ def test_pet_create_sets_owner(auth_client, user) -> None: assert pet.is_public is False +@pytest.mark.django_db +def test_pet_form_shows_event_settings_block( + auth_client, + pet, +) -> None: + """Форма редактирования питомца показывает настройки отслеживаемых событий.""" + response = auth_client.get( + reverse( + "pets:pet_update", + kwargs={"pk": pet.pk}, + ) + ) + content = response.content.decode() + + assert response.status_code == 200 + assert "Отслеживаемые события" in content + assert "Выберите, какие события для своего питомца Вы хотите отслеживать" in content + assert "Включить" in content + assert "если галочка стоит, Вы сможете добавлять события этого типа" in content + assert "Поставьте галочку, если это важно для Вас" not in content + assert "в карточке питомца появится кнопка быстрого добавления" in content + assert "поход к ветеринару или смена UV-лампы" in content + assert 'name="event_settings_submitted"' in content + assert "Кормление" in content + assert "Линька" in content + assert "Уборка" in content + assert "Измерение" in content + assert "Свои события" in content + assert "Другое" not in content + + +@pytest.mark.django_db +def test_pet_create_saves_event_settings_from_form( + auth_client, + user, +) -> None: + """При создании питомца сохраняются выбранные настройки событий.""" + response = auth_client.post( + reverse("pets:pet_create"), + data=build_pet_post_data_with_event_settings( + tracked={"cleaning": False}, + quick_actions={"measurement": False}, + ), + ) + + assert response.status_code == 302 + + pet = Pet.objects.get(name="Новый") + settings_by_slug = { + setting.event_type.slug: setting + for setting in PetEventSetting.objects.filter(pet=pet).select_related("event_type") + } + + assert pet.owner == user + assert set(settings_by_slug) == set(SYSTEM_EVENT_SETTING_SLUGS) + assert settings_by_slug["feeding"].is_tracked is True + assert settings_by_slug["feeding"].show_quick_action is True + assert settings_by_slug["cleaning"].is_tracked is False + assert settings_by_slug["cleaning"].show_quick_action is False + assert settings_by_slug["measurement"].is_tracked is True + assert settings_by_slug["measurement"].show_quick_action is False + assert all(setting.source == PetEventSetting.Source.USER for setting in settings_by_slug.values()) + + +@pytest.mark.django_db +def test_pet_update_saves_event_settings_and_updates_quick_buttons( + auth_client, + pet, +) -> None: + """Настройки из формы редактирования управляют быстрыми кнопками питомца.""" + response = auth_client.post( + reverse( + "pets:pet_update", + kwargs={"pk": pet.pk}, + ), + data=build_pet_post_data_with_event_settings( + name=pet.name, + tracked={ + "shedding": False, + "custom": False, + }, + quick_actions={ + "cleaning": False, + }, + ), + ) + + assert response.status_code == 302 + + settings_by_slug = { + setting.event_type.slug: setting + for setting in PetEventSetting.objects.filter(pet=pet).select_related("event_type") + } + detail_response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + + assert set(settings_by_slug) == set(SYSTEM_EVENT_SETTING_SLUGS) + assert settings_by_slug["feeding"].is_tracked is True + assert settings_by_slug["feeding"].show_quick_action is True + assert settings_by_slug["cleaning"].is_tracked is True + assert settings_by_slug["cleaning"].show_quick_action is False + assert settings_by_slug["shedding"].is_tracked is False + assert settings_by_slug["shedding"].show_quick_action is False + assert settings_by_slug["custom"].is_tracked is False + assert settings_by_slug["custom"].show_quick_action is False + assert [action["slug"] for action in detail_response.context["quick_event_actions"]] == [ + "feeding", + "measurement", + ] + + +@pytest.mark.django_db +def test_pet_update_event_settings_do_not_hide_event_history( + auth_client, + user, + pet, +) -> None: + """История событий остаётся видимой после выключения отслеживания.""" + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CLEANING, + event_datetime=timezone.now(), + comment="Плановая уборка", + ) + + response = auth_client.post( + reverse( + "pets:pet_update", + kwargs={"pk": pet.pk}, + ), + data=build_pet_post_data_with_event_settings( + name=pet.name, + tracked={"cleaning": False}, + ), + ) + detail_response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + + assert response.status_code == 302 + assert [row["event"].event_type for row in detail_response.context["pet_event_rows"]] == [Event.EventType.CLEANING] + + +@pytest.mark.django_db +def test_other_user_cannot_update_pet_event_settings( + other_auth_client, + pet, +) -> None: + """Чужой пользователь не может изменить настройки событий питомца.""" + response = other_auth_client.post( + reverse( + "pets:pet_update", + kwargs={"pk": pet.pk}, + ), + data=build_pet_post_data_with_event_settings( + name=pet.name, + tracked={"feeding": False}, + ), + ) + + assert response.status_code == 403 + assert PetEventSetting.objects.filter(pet=pet).exists() is False + + +@pytest.mark.django_db +def test_moderator_can_update_pet_event_settings( + moderator_client, + pet, +) -> None: + """Модератор может изменить настройки событий питомца.""" + response = moderator_client.post( + reverse( + "pets:pet_update", + kwargs={"pk": pet.pk}, + ), + data=build_pet_post_data_with_event_settings( + name=pet.name, + tracked={"feeding": False}, + ), + ) + + feeding_setting = PetEventSetting.objects.get( + pet=pet, + event_type__slug=Event.EventType.FEEDING, + ) + + assert response.status_code == 302 + assert feeding_setting.is_tracked is False + assert feeding_setting.show_quick_action is False + + @pytest.mark.django_db def test_public_pet_cannot_be_created_without_published_rules( auth_client, @@ -309,6 +548,309 @@ def test_event_create_for_user_uses_request_user_as_owner( assert event.event_type == Event.EventType.FEEDING +@pytest.mark.django_db +def test_pet_detail_uses_legacy_quick_event_buttons_without_settings( + auth_client, + pet, +) -> None: + """Без настроек питомца детальная страница показывает старый набор быстрых кнопок.""" + response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + content = response.content.decode() + + assert response.status_code == 200 + assert reverse("pets:event_create", kwargs={"event_type": "feeding"}) in content + assert reverse("pets:event_create", kwargs={"event_type": "shedding"}) in content + assert reverse("pets:event_create", kwargs={"event_type": "cleaning"}) in content + assert reverse("pets:event_create", kwargs={"event_type": "measurement"}) in content + assert reverse("pets:event_create", kwargs={"event_type": "custom"}) in content + + +@pytest.mark.django_db +def test_pet_detail_quick_event_buttons_follow_pet_settings( + auth_client, + pet, +) -> None: + """Быстрые кнопки учитывают настройки отслеживания конкретного питомца.""" + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + cleaning_type = EventType.objects.get(slug=Event.EventType.CLEANING) + shedding_type = EventType.objects.get(slug=Event.EventType.SHEDDING) + + PetEventSetting.objects.create( + pet=pet, + event_type=feeding_type, + is_tracked=True, + show_quick_action=True, + ) + PetEventSetting.objects.create( + pet=pet, + event_type=cleaning_type, + is_tracked=True, + show_quick_action=False, + ) + PetEventSetting.objects.create( + pet=pet, + event_type=shedding_type, + is_tracked=False, + show_quick_action=True, + ) + + response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + quick_event_actions = response.context["quick_event_actions"] + + assert response.status_code == 200 + assert [action["slug"] for action in quick_event_actions] == ["feeding"] + + +@pytest.mark.django_db +def test_pet_detail_hides_quick_button_for_inactive_event_type( + auth_client, + pet, +) -> None: + """Неактивный тип события не появляется в быстрых кнопках.""" + measurement_type = EventType.objects.get(slug=Event.EventType.MEASUREMENT) + measurement_type.is_active = False + measurement_type.save(update_fields=["is_active"]) + + PetEventSetting.objects.create( + pet=pet, + event_type=measurement_type, + is_tracked=True, + show_quick_action=True, + ) + + response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + + assert response.status_code == 200 + assert response.context["quick_event_actions"] == [] + + +@pytest.mark.django_db +def test_pet_detail_event_history_is_visible_independent_of_settings( + auth_client, + user, + pet, +) -> None: + """История событий не зависит от текущих настроек быстрых кнопок.""" + cleaning_type = EventType.objects.get(slug=Event.EventType.CLEANING) + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CLEANING, + event_datetime=timezone.now(), + comment="Плановая уборка", + ) + PetEventSetting.objects.create( + pet=pet, + event_type=cleaning_type, + is_tracked=False, + show_quick_action=False, + ) + + response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + pet_event_rows = response.context["pet_event_rows"] + + assert response.status_code == 200 + assert [row["event"].event_type for row in pet_event_rows] == [Event.EventType.CLEANING] + assert response.context["quick_event_actions"] == [] + + +@pytest.mark.django_db +def test_event_create_url_works_when_quick_button_is_hidden( + auth_client, + user, + pet, +) -> None: + """Прямой URL создания события остаётся рабочим независимо от быстрых кнопок.""" + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + PetEventSetting.objects.create( + pet=pet, + event_type=feeding_type, + is_tracked=False, + show_quick_action=False, + ) + + get_response = auth_client.get( + reverse( + "pets:event_create", + kwargs={"event_type": "feeding"}, + ), + data={"pet": pet.pk}, + ) + + assert get_response.status_code == 200 + assert "Это событие выключено в настройках питомца" in get_response.content.decode() + + response = auth_client.post( + reverse( + "pets:event_create", + kwargs={"event_type": "feeding"}, + ), + data={ + "pet": pet.pk, + "event_type": "feeding", + "event_datetime": timezone.now().strftime("%Y-%m-%dT%H:%M"), + "no_handling_days": 1, + "repeat_after_days": 7, + "comment": "Покормить напрямую", + }, + ) + + assert response.status_code == 302 + assert Event.objects.filter( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + comment="Покормить напрямую", + ).exists() + + +@pytest.mark.django_db +def test_event_create_is_blocked_when_animal_type_rule_disallows_event( + auth_client, + user, + pet, +) -> None: + """Системно недоступное событие не создаётся, но страница формы не падает.""" + feeding_rule = AnimalTypeEventRule.objects.get( + animal_type=pet.animal_type, + event_type__slug=Event.EventType.FEEDING, + ) + feeding_rule.is_available = False + feeding_rule.save(update_fields=["is_available"]) + + get_response = auth_client.get( + reverse( + "pets:event_create", + kwargs={"event_type": "feeding"}, + ), + data={"pet": pet.pk}, + ) + get_content = get_response.content.decode() + + assert get_response.status_code == 200 + assert "Это событие недоступно для змей" in get_content + assert "Создать событие" in get_content + assert "disabled" in get_content + + post_response = auth_client.post( + reverse( + "pets:event_create", + kwargs={"event_type": "feeding"}, + ), + data={ + "pet": pet.pk, + "event_type": "feeding", + "event_datetime": timezone.now().strftime("%Y-%m-%dT%H:%M"), + "no_handling_days": 1, + "repeat_after_days": 7, + "comment": "Запрещённое кормление", + }, + ) + + 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 + + +@pytest.mark.django_db +def test_event_create_prefills_datetime_in_user_timezone( + auth_client, + monkeypatch, + user, + pet, +) -> None: + """Форма создания события получает текущее время в часовом поясе пользователя.""" + user.time_zone = "UTC+03:00" + user.save(update_fields=["time_zone"]) + fixed_now = datetime(2026, 7, 28, 10, 15, 40, tzinfo=dt_timezone.utc) + monkeypatch.setattr( + "users.timezone_services.timezone.now", + lambda: fixed_now, + ) + + response = auth_client.get( + reverse( + "pets:event_create", + kwargs={"event_type": "feeding"}, + ), + data={"pet": pet.pk}, + ) + + assert response.status_code == 200 + assert response.context["form"].initial["event_datetime"] == "2026-07-28T13:15" + assert 'value="2026-07-28T13:15"' in response.content.decode() + + +@pytest.mark.django_db +def test_event_update_keeps_existing_datetime( + auth_client, + user, + pet, +) -> None: + """Редактирование события не заменяет сохранённую дату текущим временем.""" + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=datetime(2026, 7, 27, 8, 30, tzinfo=dt_timezone.utc), + repeat_after_days=7, + no_handling_days=1, + ) + + response = auth_client.get( + reverse( + "pets:event_update", + kwargs={"pk": event.pk}, + ) + ) + + assert response.status_code == 200 + assert 'value="2026-07-27T08:30"' in response.content.decode() + + +@pytest.mark.django_db +def test_pet_list_pet_name_links_to_detail( + auth_client, + pet, +) -> None: + """Имя питомца в гриде ведёт на детальную страницу.""" + response = auth_client.get(reverse("pets:pet_list")) + content = response.content.decode() + pet_detail_url = reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + + assert response.status_code == 200 + assert f'href="{pet_detail_url}"' in content + assert pet.name in content + + @pytest.mark.django_db def test_public_pet_creation_is_atomic_when_acceptance_fails( auth_client,