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 @@
Выберите, какие события для своего питомца Вы хотите отслеживать:
+