diff --git a/legal/services.py b/legal/services.py index 1f431e6..65cd36b 100644 --- a/legal/services.py +++ b/legal/services.py @@ -1,9 +1,9 @@ from typing import cast from django.contrib.auth.base_user import AbstractBaseUser -from django.utils import timezone from django.core.exceptions import ValidationError from django.db import transaction +from django.utils import timezone from .models import LegalDocumentVersion, UserLegalAcceptance diff --git a/pets/admin.py b/pets/admin.py index 531c050..5a4c44b 100644 --- a/pets/admin.py +++ b/pets/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.db.models.functions import Coalesce from .models import AnimalTypeEventRule, CareRule, Event, EventType, Pet, PetCondition, PetEventSetting, PlannedEvent @@ -171,6 +172,8 @@ class PetEventSettingAdmin(admin.ModelAdmin): "event_type", "is_tracked", "show_quick_action", + "default_no_handling_days", + "no_handling_during_shedding", "source", "created_at", ) @@ -315,8 +318,8 @@ class EventAdmin(admin.ModelAdmin): "pet", "event_type", "event_type_ref", - "event_datetime", - "happened_at", + "event_happened_at_display", + "ended_at", "owner", "repeat_after_days", "no_handling_days", @@ -341,7 +344,7 @@ class EventAdmin(admin.ModelAdmin): autocomplete_fields = ("owner", "pet", "event_type_ref") readonly_fields = ("created_at",) list_select_related = ("owner", "pet", "event_type_ref") - ordering = ("-event_datetime",) + ordering = () fieldsets = ( ( @@ -376,6 +379,20 @@ class EventAdmin(admin.ModelAdmin): ), ) + def get_queryset(self, request): + """Сортирует события в админке по новой дате факта с fallback на старое поле.""" + return ( + super() + .get_queryset(request) + .annotate(event_happened_at_sort=Coalesce("happened_at", "event_datetime")) + .order_by("-event_happened_at_sort", "-pk") + ) + + @admin.display(description="Дата факта", ordering="event_happened_at_sort") + def event_happened_at_display(self, obj: Event): + """Показывает фактическое время события в переходный период.""" + return obj.happened_at or obj.event_datetime + def formfield_for_dbfield(self, db_field, request, **kwargs): """Уточняет подписи переходных полей даты события в админке.""" if db_field.name == "event_datetime": diff --git a/pets/forms.py b/pets/forms.py index 8ed3ab3..9240dd1 100644 --- a/pets/forms.py +++ b/pets/forms.py @@ -177,7 +177,7 @@ class Meta: "pet": "Питомец", "event_type": "Тип события", "title": "Название", - "event_datetime": "Дата и время", + "event_datetime": "Дата", "comment": "Комментарий", "repeat_after_days": "Повторить через (дней)", "no_handling_days": "Нельзя трогать (дней)", @@ -285,6 +285,10 @@ class Meta(BaseEventForm.Meta): "no_handling_days", "comment", ] + labels = { + **BaseEventForm.Meta.labels, + "event_datetime": "Дата начала", + } help_texts = { "no_handling_days": "Сколько дней нельзя трогать", } diff --git a/pets/migrations/0016_peteventsetting_comment_and_more.py b/pets/migrations/0016_peteventsetting_comment_and_more.py new file mode 100644 index 0000000..d637b94 --- /dev/null +++ b/pets/migrations/0016_peteventsetting_comment_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.13 on 2026-07-30 17:57 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pets', '0015_event_happened_at'), + ] + + operations = [ + migrations.AddField( + model_name='peteventsetting', + name='comment', + field=models.TextField(blank=True, verbose_name='Комментарий к настройке'), + ), + migrations.AddField( + model_name='peteventsetting', + name='default_no_handling_days', + field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(365)], verbose_name='Запрет контакта по умолчанию (дней)'), + ), + ] diff --git a/pets/migrations/0017_peteventsetting_no_handling_during_shedding.py b/pets/migrations/0017_peteventsetting_no_handling_during_shedding.py new file mode 100644 index 0000000..24c44a0 --- /dev/null +++ b/pets/migrations/0017_peteventsetting_no_handling_during_shedding.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.13 on 2026-07-30 18:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pets', '0016_peteventsetting_comment_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='peteventsetting', + name='no_handling_during_shedding', + field=models.BooleanField(default=False, verbose_name='Не трогать во время линьки'), + ), + ] diff --git a/pets/migrations/0018_peteventsetting_shedding_defaults.py b/pets/migrations/0018_peteventsetting_shedding_defaults.py new file mode 100644 index 0000000..af2c427 --- /dev/null +++ b/pets/migrations/0018_peteventsetting_shedding_defaults.py @@ -0,0 +1,48 @@ +from django.db import migrations + + +def apply_shedding_handling_defaults(apps, schema_editor): + AnimalTypeEventRule = apps.get_model("pets", "AnimalTypeEventRule") + EventType = apps.get_model("pets", "EventType") + PetEventSetting = apps.get_model("pets", "PetEventSetting") + + shedding_type = EventType.objects.filter(slug="shedding").first() + if not shedding_type: + return + + sensitive_animal_types = set( + AnimalTypeEventRule.objects.filter( + event_type=shedding_type, + is_active=True, + is_available=True, + shedding_policy__in=("handling_sensitive", "critical"), + ).values_list("animal_type", flat=True) + ) + + if not sensitive_animal_types: + return + + PetEventSetting.objects.filter( + event_type=shedding_type, + pet__animal_type__in=sensitive_animal_types, + ).update(no_handling_during_shedding=True) + + +def clear_shedding_handling_defaults(apps, schema_editor): + EventType = apps.get_model("pets", "EventType") + PetEventSetting = apps.get_model("pets", "PetEventSetting") + + shedding_type = EventType.objects.filter(slug="shedding").first() + if shedding_type: + PetEventSetting.objects.filter(event_type=shedding_type).update(no_handling_during_shedding=False) + + +class Migration(migrations.Migration): + + dependencies = [ + ("pets", "0017_peteventsetting_no_handling_during_shedding"), + ] + + operations = [ + migrations.RunPython(apply_shedding_handling_defaults, clear_shedding_handling_defaults), + ] diff --git a/pets/migrations/0019_event_ended_at.py b/pets/migrations/0019_event_ended_at.py new file mode 100644 index 0000000..0e2dce4 --- /dev/null +++ b/pets/migrations/0019_event_ended_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.13 on 2026-07-30 19:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pets', '0018_peteventsetting_shedding_defaults'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='ended_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Когда закончилось'), + ), + ] diff --git a/pets/models.py b/pets/models.py index bf43071..69a4180 100644 --- a/pets/models.py +++ b/pets/models.py @@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.db.models.functions import Coalesce from django.urls import reverse from django.utils import timezone @@ -371,6 +372,20 @@ class Source(models.TextChoices): default=True, verbose_name="Показывать быструю кнопку", ) + default_no_handling_days = models.PositiveIntegerField( + blank=True, + null=True, + verbose_name="Запрет контакта по умолчанию (дней)", + validators=[MaxValueValidator(365)], + ) + no_handling_during_shedding = models.BooleanField( + default=False, + verbose_name="Не трогать во время линьки", + ) + comment = models.TextField( + blank=True, + verbose_name="Комментарий к настройке", + ) source = models.CharField( max_length=20, choices=Source.choices, @@ -800,6 +815,12 @@ class EventType(models.TextChoices): help_text="Переходное поле для будущей архитектуры событий. Старое поле event_datetime остаётся рабочим.", ) + ended_at = models.DateTimeField( + blank=True, + null=True, + verbose_name="Когда закончилось", + ) + comment = models.TextField( blank=True, verbose_name="Комментарий", @@ -877,6 +898,14 @@ def clean(self): if self.event_datetime and self.event_datetime > timezone.now(): raise ValidationError({"event_datetime": "Дата и время события не могут быть в будущем."}) + if self.ended_at: + if self.ended_at > timezone.now(): + raise ValidationError({"ended_at": "Дата и время окончания события не могут быть в будущем."}) + + start_dt = self.happened_at or self.event_datetime + if start_dt and self.ended_at <= start_dt: + raise ValidationError({"ended_at": "Окончание события должно быть позже начала."}) + def save(self, *args, **kwargs): """Сохраняет событие и обновляет параметры питомца, если это событие измерения.""" @@ -923,7 +952,8 @@ def get_latest_measurement_event_for_field(self, field_name: str) -> "Event | No event_type=self.EventType.MEASUREMENT, **{f"{field_name}__isnull": False}, ) - .order_by("-event_datetime", "-pk") + .annotate(event_happened_at_sort=Coalesce("happened_at", "event_datetime")) + .order_by("-event_happened_at_sort", "-pk") .first() ) diff --git a/pets/services.py b/pets/services.py index 304ea94..0df04f6 100644 --- a/pets/services.py +++ b/pets/services.py @@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError from django.db import transaction from django.db.models import Q +from django.db.models.functions import Coalesce from django.utils import timezone from .models import AnimalTypeEventRule, CareRule, Event, EventType, Pet, PetCondition, PetEventSetting, PlannedEvent @@ -15,9 +16,38 @@ 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_NO_HANDLING_FIELD_TEMPLATE = "event_setting_{slug}_default_no_handling_days" +EVENT_SETTING_COMMENT_FIELD_TEMPLATE = "event_setting_{slug}_comment" +EVENT_SETTING_INTERVAL_VALUE_FIELD_TEMPLATE = "event_setting_{slug}_interval_value" +EVENT_SETTING_INTERVAL_UNIT_FIELD_TEMPLATE = "event_setting_{slug}_interval_unit" +EVENT_SETTING_RATION_FIELD_TEMPLATE = "event_setting_{slug}_ration" +EVENT_SETTING_SHEDDING_NO_HANDLING_FIELD_TEMPLATE = "event_setting_{slug}_no_handling_during_shedding" EVENT_SETTING_DISPLAY_NAMES = { cast(str, Event.EventType.CUSTOM): "Свои события", } +EVENT_SETTING_DEFAULT_INTERVALS = { + cast(str, Event.EventType.MEASUREMENT): 30, +} +EVENT_SETTING_INTERVAL_SLUGS = { + cast(str, Event.EventType.FEEDING), + cast(str, Event.EventType.CLEANING), + cast(str, Event.EventType.MEASUREMENT), +} +EVENT_SETTING_NO_HANDLING_DAYS_SLUGS = { + cast(str, Event.EventType.FEEDING), +} +EVENT_SETTING_COMMENT_SLUGS = { + cast(str, Event.EventType.FEEDING), + cast(str, Event.EventType.SHEDDING), + cast(str, Event.EventType.CLEANING), + cast(str, Event.EventType.MEASUREMENT), +} +EVENT_SETTING_RATION_SLUGS = { + cast(str, Event.EventType.FEEDING), +} +EVENT_SETTING_SHEDDING_NO_HANDLING_SLUGS = { + cast(str, Event.EventType.SHEDDING), +} EVENT_DISABLED_WARNING = ( "Это событие выключено в настройках питомца. " "Вы всё ещё можете добавить его по прямой ссылке, " @@ -65,6 +95,13 @@ cast(str, Event.EventType.CLEANING): "Убраться", cast(str, Event.EventType.MEASUREMENT): "Измерить", } +DIRECT_COMPLETE_PLANNED_EVENT_TYPES = { + cast(str, Event.EventType.FEEDING), + cast(str, Event.EventType.CLEANING), +} +FORM_COMPLETE_PLANNED_EVENT_TYPES = { + cast(str, Event.EventType.MEASUREMENT), +} @dataclass(frozen=True) @@ -108,6 +145,34 @@ class UpcomingPetTask: planned_event_id: int | None = None +@dataclass(frozen=True) +class UpcomingPetTaskRow: + """Хранит строку ближайшей задачи ухода для интерфейса питомца.""" + + label: str + event_type_slug: str + event_type_name: str + next_datetime: datetime + text: str + planned_event_id: int | None = None + scheduled_at_initial: str = "" + can_complete: bool = False + requires_event_form: bool = False + is_overdue: bool = False + + +@dataclass(frozen=True) +class ActiveSheddingRow: + """Хранит строку активной линьки для ближайших событий питомца.""" + + condition_id: int + started_at: datetime + text: str + started_at_initial: str + ended_at_initial: str + comment: str = "" + + @dataclass(frozen=True) class PlannedEventGenerationResult: """Хранит сводку генерации ближайших плановых задач из правил ухода.""" @@ -207,6 +272,11 @@ def get_event_happened_at(event: Event) -> datetime: return event.happened_at or event.event_datetime +def _annotate_event_happened_at_sort(queryset): + """Добавляет техническую дату факта для сортировки переходных событий.""" + return queryset.annotate(event_happened_at_sort=Coalesce("happened_at", "event_datetime")) + + def get_next_repeat_datetime(event: Event) -> datetime | None: """Вычисляет дату и время следующего повторения события.""" if not event.repeat_after_days: @@ -243,9 +313,12 @@ def _get_care_rule_start_datetime(care_rule: CareRule) -> datetime: def get_latest_event_for_care_rule(care_rule: CareRule) -> Event | None: """Возвращает последнее фактическое событие, подходящее под правило ухода.""" return ( - Event.objects.filter(pet_id=care_rule.pet_id) - .filter(Q(event_type_ref_id=care_rule.event_type_id) | Q(event_type=care_rule.event_type.slug)) - .order_by("-event_datetime", "-pk") + _annotate_event_happened_at_sort( + Event.objects.filter(pet_id=care_rule.pet_id).filter( + Q(event_type_ref_id=care_rule.event_type_id) | Q(event_type=care_rule.event_type.slug) + ) + ) + .order_by("-event_happened_at_sort", "-pk") .first() ) @@ -275,6 +348,35 @@ def get_no_handling_until(event: Event) -> datetime | None: return get_event_happened_at(event) + timedelta(days=event.no_handling_days) +def sync_no_handling_condition_from_event(event: Event) -> PetCondition | None: + """Синхронизирует активное состояние запрета контакта с фактом события.""" + PetCondition.objects.filter( + source_event=event, + condition_type=PetCondition.ConditionType.NO_HANDLING, + ).delete() + + ended_at = get_no_handling_until(event) + if not ended_at or ended_at <= timezone.now(): + return None + + return PetCondition.objects.create( + pet=event.pet, + condition_type=PetCondition.ConditionType.NO_HANDLING, + started_at=get_event_happened_at(event), + ended_at=ended_at, + is_active=True, + source_event=event, + comment=event.comment, + created_by=event.pet.owner, + ) + + +def delete_conditions_from_event(event: Event) -> int: + """Удаляет производные состояния, которые были созданы из факта события.""" + deleted_count, _deleted_by_model = PetCondition.objects.filter(source_event=event).delete() + return deleted_count + + def _format_signed_diff(value: Decimal | int | float) -> str: """Форматирует числовую разницу со знаком для отображения в интерфейсе.""" decimal_value = Decimal(str(value)) @@ -289,14 +391,19 @@ def _format_signed_diff(value: Decimal | int | float) -> str: def get_previous_measurement_event(event: Event) -> Event | None: """Возвращает предыдущее событие измерения для того же питомца.""" - if not event.pet_id or not event.event_datetime: + if not event.pet_id: + return None + + event_happened_at = get_event_happened_at(event) + if not event_happened_at: return None - qs = Event.objects.filter( - pet_id=event.pet_id, - event_type=Event.EventType.MEASUREMENT, - event_datetime__lt=event.event_datetime, - ).order_by("-event_datetime", "-pk") + qs = _annotate_event_happened_at_sort( + Event.objects.filter( + pet_id=event.pet_id, + event_type=Event.EventType.MEASUREMENT, + ) + ).filter(event_happened_at_sort__lt=event_happened_at).order_by("-event_happened_at_sort", "-pk") if event.pk: qs = qs.exclude(pk=event.pk) @@ -307,11 +414,13 @@ def get_previous_measurement_event(event: Event) -> Event | None: def get_latest_measurement_event_for_field(pet: Pet, field_name: str) -> Event | None: """Возвращает последнее событие измерения, где заполнено указанное поле.""" return ( - pet.events.filter( - event_type=Event.EventType.MEASUREMENT, - **{f"{field_name}__isnull": False}, + _annotate_event_happened_at_sort( + pet.events.filter( + event_type=Event.EventType.MEASUREMENT, + **{f"{field_name}__isnull": False}, + ) ) - .order_by("-event_datetime", "-pk") + .order_by("-event_happened_at_sort", "-pk") .first() ) @@ -395,9 +504,10 @@ def backfill_care_rules_from_repeat_events(*, apply_changes: bool = False) -> Ca event_types_by_slug = {event_type.slug: event_type for event_type in EventType.objects.all()} repeat_events = ( - Event.objects.filter(repeat_after_days__isnull=False) - .select_related("owner", "pet", "event_type_ref") - .order_by("pet_id", "event_type", "-event_datetime", "-pk") + _annotate_event_happened_at_sort( + Event.objects.filter(repeat_after_days__isnull=False).select_related("owner", "pet", "event_type_ref") + ) + .order_by("pet_id", "event_type", "-event_happened_at_sort", "-pk") ) for event in repeat_events: @@ -555,6 +665,198 @@ def generate_planned_events_from_care_rules(*, apply_changes: bool = False) -> P ) +def generate_next_planned_event_for_care_rule(care_rule: CareRule) -> PlannedEvent | None: + """Создаёт ближайшую плановую задачу для правила ухода без дублей.""" + if not care_rule.is_active or not care_rule.event_type.is_active or not care_rule.event_type.can_be_planned: + return None + + existing_planned_event = PlannedEvent.objects.filter( + care_rule=care_rule, + status=PlannedEvent.Status.PLANNED, + ).first() + if existing_planned_event: + return existing_planned_event + + scheduled_at = get_next_care_rule_datetime(care_rule) + if not scheduled_at: + return None + + return PlannedEvent.objects.create( + pet=care_rule.pet, + event_type=care_rule.event_type, + scheduled_at=scheduled_at, + status=PlannedEvent.Status.PLANNED, + care_rule=care_rule, + created_by=care_rule.created_by, + ) + + +def sync_planned_event_for_care_rule(care_rule: CareRule) -> PlannedEvent | None: + """Пересчитывает открытую плановую задачу правила ухода от текущей истории событий.""" + if not care_rule.is_active or not care_rule.event_type.is_active or not care_rule.event_type.can_be_planned: + return None + + scheduled_at = get_next_care_rule_datetime(care_rule) + if not scheduled_at: + return None + + planned_event = ( + PlannedEvent.objects.filter( + care_rule=care_rule, + status=PlannedEvent.Status.PLANNED, + ) + .order_by("scheduled_at", "pk") + .first() + ) + + if planned_event: + planned_event.scheduled_at = scheduled_at + planned_event.save(update_fields=["scheduled_at", "updated_at"]) + return planned_event + + return PlannedEvent.objects.create( + pet=care_rule.pet, + event_type=care_rule.event_type, + scheduled_at=scheduled_at, + status=PlannedEvent.Status.PLANNED, + care_rule=care_rule, + created_by=care_rule.created_by, + ) + + +def sync_planned_events_for_pet_event_type(*, pet: Pet, event_type: EventType) -> list[PlannedEvent]: + """Пересчитывает плановые задачи питомца для типа события после изменения истории.""" + synced_events: list[PlannedEvent] = [] + care_rules = CareRule.objects.select_related("pet", "event_type", "created_by").filter( + pet=pet, + event_type=event_type, + is_active=True, + ) + + for care_rule in care_rules: + planned_event = sync_planned_event_for_care_rule(care_rule) + if planned_event: + synced_events.append(planned_event) + + return synced_events + + +def sync_planned_events_after_event_change(event: Event) -> list[PlannedEvent]: + """Пересчитывает плановые задачи после создания, редактирования или удаления факта Event.""" + event_type = event.event_type_ref or EventType.objects.filter(slug=event.event_type).first() + if not event_type: + return [] + + return sync_planned_events_for_pet_event_type(pet=event.pet, event_type=event_type) + + +def reschedule_planned_event(planned_event: PlannedEvent, scheduled_at: datetime) -> PlannedEvent: + """Меняет дату ближайшей плановой задачи без изменения правила регулярности.""" + if planned_event.status != PlannedEvent.Status.PLANNED: + raise ValidationError("Изменить дату можно только у запланированной задачи.") + + planned_event.scheduled_at = scheduled_at + planned_event.save(update_fields=["scheduled_at", "updated_at"]) + return planned_event + + +def start_shedding_condition( + *, + pet: Pet, + started_at: datetime, + comment: str = "", + created_by, +) -> PetCondition: + """Создаёт активное состояние линьки без создания факта Event.""" + if pet_has_active_condition(pet, PetCondition.ConditionType.SHEDDING): + raise ValidationError("У питомца уже есть активная линька.") + + if started_at > timezone.now(): + raise ValidationError({"started_at": "Дата и время начала линьки не могут быть в будущем."}) + + return PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.SHEDDING, + started_at=started_at, + is_active=True, + comment=comment.strip(), + created_by=created_by, + ) + + +def finish_shedding_condition( + *, + condition: PetCondition, + started_at: datetime, + ended_at: datetime, + comment: str = "", +) -> Event: + """Завершает активную линьку и создаёт факт линьки с началом и окончанием.""" + if condition.condition_type != PetCondition.ConditionType.SHEDDING: + raise ValidationError("Завершить как линьку можно только состояние линьки.") + + if not condition.is_active: + raise ValidationError("Эта линька уже завершена.") + + shedding_type = EventType.objects.filter(slug=Event.EventType.SHEDDING).first() + start_comment = condition.comment.strip() + finish_comment = comment.strip() + if start_comment and finish_comment.startswith(start_comment): + event_comment = finish_comment + else: + event_comment = "\n\n".join(part for part in (start_comment, finish_comment) if part) + + with transaction.atomic(): + event = Event.objects.create( + owner=condition.pet.owner, + pet=condition.pet, + event_type=Event.EventType.SHEDDING, + event_type_ref=shedding_type, + event_datetime=started_at, + happened_at=started_at, + ended_at=ended_at, + comment=event_comment, + ) + + condition.started_at = started_at + condition.ended_at = ended_at + condition.comment = event_comment + condition.is_active = False + condition.end_event = event + condition.save(update_fields=["started_at", "ended_at", "comment", "is_active", "end_event", "updated_at"]) + + return event + + +def can_complete_planned_event_directly(planned_event: PlannedEvent) -> bool: + """Проверяет, можно ли выполнить плановую задачу без дополнительной формы.""" + return planned_event.event_type.slug in DIRECT_COMPLETE_PLANNED_EVENT_TYPES + + +def get_default_no_handling_days_for_planned_event(planned_event: PlannedEvent) -> int | None: + """Возвращает ограничение контакта по умолчанию для плановой задачи.""" + setting = PetEventSetting.objects.filter( + pet=planned_event.pet, + event_type=planned_event.event_type, + is_tracked=True, + ).first() + + if setting and setting.default_no_handling_days is not None: + return setting.default_no_handling_days + + rule = AnimalTypeEventRule.objects.filter( + animal_type=planned_event.pet.animal_type, + event_type=planned_event.event_type, + is_active=True, + is_available=True, + ).first() + + if not rule: + return None + + return rule.default_no_handling_days + + def complete_planned_event( planned_event: PlannedEvent, *, @@ -576,6 +878,9 @@ def complete_planned_event( event_title = title.strip() if title else planned_event.title event_comment = comment.strip() if comment else planned_event.comment event_happened_at = completed_at or timezone.now() + event_no_handling_days = no_handling_days + if event_no_handling_days is None: + event_no_handling_days = get_default_no_handling_days_for_planned_event(planned_event) with transaction.atomic(): event = Event.objects.create( @@ -588,10 +893,11 @@ def complete_planned_event( happened_at=event_happened_at, comment=event_comment, repeat_after_days=None, - no_handling_days=no_handling_days, + no_handling_days=event_no_handling_days, weight_grams=weight_grams, length_cm=length_cm, ) + sync_no_handling_condition_from_event(event) planned_event.status = PlannedEvent.Status.DONE planned_event.actual_event = event @@ -623,6 +929,26 @@ def _close_planned_event_without_fact(planned_event: PlannedEvent, status: str) return planned_event +def generate_next_planned_event_after_skip(planned_event: PlannedEvent) -> PlannedEvent | None: + """Создаёт следующую задачу после пропуска, считая от пропущенной плановой даты.""" + care_rule = planned_event.care_rule + if not care_rule or not care_rule.is_active or not care_rule.event_type.can_be_planned: + return None + + if PlannedEvent.objects.filter(care_rule=care_rule, status=PlannedEvent.Status.PLANNED).exists(): + return None + + scheduled_at = _add_care_rule_interval(planned_event.scheduled_at, care_rule) + return PlannedEvent.objects.create( + pet=planned_event.pet, + event_type=planned_event.event_type, + scheduled_at=scheduled_at, + status=PlannedEvent.Status.PLANNED, + care_rule=care_rule, + created_by=planned_event.created_by, + ) + + def _get_active_pet_conditions_queryset(pet: Pet, condition_type: str): """Возвращает активные состояния питомца на текущий момент.""" now = timezone.now() @@ -667,9 +993,10 @@ def backfill_pet_conditions_from_events(*, apply_changes: bool = False) -> PetCo now = timezone.now() events = ( - Event.objects.filter(no_handling_days__isnull=False) - .select_related("owner", "pet", "event_type_ref") - .order_by("pet_id", "event_type", "-event_datetime", "-pk") + _annotate_event_happened_at_sort( + Event.objects.filter(no_handling_days__isnull=False).select_related("owner", "pet", "event_type_ref") + ) + .order_by("pet_id", "event_type", "-event_happened_at_sort", "-pk") ) for event in events: @@ -755,6 +1082,7 @@ def get_measurement_comment_lines(event: Event) -> list[str]: def get_upcoming_pet_task_items(pet: Pet) -> list[UpcomingPetTask]: """Возвращает ближайшие задачи из PlannedEvent, CareRule и старых событий.""" tasks: list[UpcomingPetTask] = [] + tracked_slugs = get_pet_tracked_event_type_slugs(pet) planned_event_type_slugs: set[str] = set() planned_care_rule_ids: set[int] = set() care_rule_event_type_slugs: set[str] = set() @@ -763,6 +1091,9 @@ def get_upcoming_pet_task_items(pet: Pet) -> list[UpcomingPetTask]: status=PlannedEvent.Status.PLANNED, event_type__is_active=True, ): + if planned_event.event_type.slug not in tracked_slugs: + continue + planned_event_type_slugs.add(planned_event.event_type.slug) if planned_event.care_rule_id: planned_care_rule_ids.add(planned_event.care_rule_id) @@ -780,6 +1111,9 @@ def get_upcoming_pet_task_items(pet: Pet) -> list[UpcomingPetTask]: is_active=True, event_type__is_active=True, ): + if care_rule.event_type.slug not in tracked_slugs: + continue + if care_rule.id in planned_care_rule_ids: continue @@ -797,15 +1131,20 @@ def get_upcoming_pet_task_items(pet: Pet) -> list[UpcomingPetTask]: ) for event_type, event_type_name in UPCOMING_TASK_LABELS.items(): + if event_type not in tracked_slugs: + continue + if event_type in planned_event_type_slugs or event_type in care_rule_event_type_slugs: continue last_event = ( - pet.events.filter( - event_type=event_type, - repeat_after_days__isnull=False, + _annotate_event_happened_at_sort( + pet.events.filter( + event_type=event_type, + repeat_after_days__isnull=False, + ) ) - .order_by("-event_datetime", "-pk") + .order_by("-event_happened_at_sort", "-pk") .first() ) @@ -827,6 +1166,23 @@ def get_upcoming_pet_task_items(pet: Pet) -> list[UpcomingPetTask]: return sorted(tasks, key=lambda item: item.next_datetime) +def get_pet_tracked_event_type_slugs(pet: Pet) -> set[str]: + """Возвращает включённые типы событий питомца с fallback на старое поведение.""" + legacy_slugs = {action["slug"] for action in LEGACY_QUICK_EVENT_ACTIONS} + event_settings = pet.event_settings.select_related("event_type").filter( + event_type__slug__in=legacy_slugs, + ) + + if not event_settings.exists(): + return legacy_slugs + + return { + setting.event_type.slug + for setting in event_settings + if setting.is_tracked and setting.event_type.is_active + } + + def get_upcoming_pet_tasks(pet: Pet) -> list[str]: """Возвращает отсортированный список ближайших повторяющихся задач по питомцу для показа в интерфейсе.""" @@ -840,23 +1196,63 @@ def get_upcoming_pet_tasks(pet: Pet) -> list[str]: return 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) +def get_upcoming_pet_task_rows(pet: Pet) -> list[UpcomingPetTaskRow]: + """Возвращает ближайшие задачи ухода для карточки питомца.""" + rows: list[UpcomingPetTaskRow] = [] + now = timezone.now() - event_settings = pet.event_settings.select_related("event_type").filter( - event_type__slug__in=legacy_slugs, + for task in get_upcoming_pet_task_items(pet): + label = UPCOMING_TASK_LABELS.get(task.event_type_slug, task.event_type_name) + local_next_dt = timezone.localtime(task.next_datetime) + rows.append( + UpcomingPetTaskRow( + label=label, + event_type_slug=task.event_type_slug, + event_type_name=task.event_type_name, + next_datetime=task.next_datetime, + text=f"{label} {local_next_dt.strftime('%d.%m.%Y')}", + planned_event_id=task.planned_event_id, + scheduled_at_initial=local_next_dt.strftime("%Y-%m-%dT%H:%M"), + can_complete=bool( + task.planned_event_id and task.event_type_slug in DIRECT_COMPLETE_PLANNED_EVENT_TYPES + ), + requires_event_form=bool( + task.planned_event_id and task.event_type_slug in FORM_COMPLETE_PLANNED_EVENT_TYPES + ), + is_overdue=task.next_datetime < now, + ) + ) + + return rows + + +def get_active_shedding_row(pet: Pet) -> ActiveSheddingRow | None: + """Возвращает активную линьку питомца для блока ближайших событий.""" + condition = ( + _get_active_pet_conditions_queryset(pet, PetCondition.ConditionType.SHEDDING) + .order_by("-started_at", "-pk") + .first() ) - if not event_settings.exists(): - return [dict(action) for action in LEGACY_QUICK_EVENT_ACTIONS] + if not condition: + return None - 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 - } + local_started_at = timezone.localtime(condition.started_at) + local_ended_at = timezone.localtime(condition.ended_at or timezone.now()) + return ActiveSheddingRow( + condition_id=condition.id, + started_at=condition.started_at, + text=f"В линьке с {local_started_at:%d.%m.%Y}", + started_at_initial=local_started_at.strftime("%Y-%m-%dT%H:%M"), + ended_at_initial=local_ended_at.strftime("%Y-%m-%dT%H:%M"), + comment=condition.comment, + ) + + +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} + visible_slugs = get_pet_tracked_event_type_slugs(pet) return [dict(action) for action in LEGACY_QUICK_EVENT_ACTIONS if action["slug"] in visible_slugs] @@ -910,6 +1306,8 @@ def get_pet_event_setting_rows(pet: Pet | None, data=None) -> list[dict[str, obj ) } settings_by_slug: dict[str, PetEventSetting] = {} + care_rules_by_slug: dict[str, CareRule] = {} + animal_rules_by_slug: dict[str, AnimalTypeEventRule] = {} if pet and pet.pk: settings_by_slug = { @@ -918,6 +1316,22 @@ def get_pet_event_setting_rows(pet: Pet | None, data=None) -> list[dict[str, obj event_type__slug__in=legacy_slugs, ) } + care_rules_by_slug = { + care_rule.event_type.slug: care_rule + for care_rule in pet.care_rules.select_related("event_type").filter( + event_type__slug__in=legacy_slugs, + is_active=True, + ) + } + animal_rules_by_slug = { + rule.event_type.slug: rule + for rule in AnimalTypeEventRule.objects.select_related("event_type").filter( + animal_type=pet.animal_type, + event_type__slug__in=legacy_slugs, + is_active=True, + is_available=True, + ) + } rows: list[dict[str, object]] = [] use_posted_values = bool(data is not None and EVENT_SETTINGS_SUBMITTED_FIELD in data) @@ -930,30 +1344,220 @@ def get_pet_event_setting_rows(pet: Pet | None, data=None) -> list[dict[str, obj 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) + no_handling_field_name = EVENT_SETTING_NO_HANDLING_FIELD_TEMPLATE.format(slug=slug) + comment_field_name = EVENT_SETTING_COMMENT_FIELD_TEMPLATE.format(slug=slug) + interval_value_field_name = EVENT_SETTING_INTERVAL_VALUE_FIELD_TEMPLATE.format(slug=slug) + interval_unit_field_name = EVENT_SETTING_INTERVAL_UNIT_FIELD_TEMPLATE.format(slug=slug) + ration_field_name = EVENT_SETTING_RATION_FIELD_TEMPLATE.format(slug=slug) + shedding_no_handling_field_name = EVENT_SETTING_SHEDDING_NO_HANDLING_FIELD_TEMPLATE.format(slug=slug) + setting = settings_by_slug.get(slug) + care_rule = care_rules_by_slug.get(slug) + animal_rule = animal_rules_by_slug.get(slug) + show_interval_field = slug in EVENT_SETTING_INTERVAL_SLUGS + show_no_handling_days_field = slug in EVENT_SETTING_NO_HANDLING_DAYS_SLUGS + show_comment_field = slug in EVENT_SETTING_COMMENT_SLUGS + show_ration_field = slug in EVENT_SETTING_RATION_SLUGS + show_shedding_no_handling_field = slug in EVENT_SETTING_SHEDDING_NO_HANDLING_SLUGS + can_configure = any( + [ + show_interval_field, + show_no_handling_days_field, + show_comment_field, + show_ration_field, + show_shedding_no_handling_field, + ] + ) if use_posted_values: is_tracked = tracked_field_name in data - show_quick_action = is_tracked and quick_action_field_name in data + default_no_handling_days = ( + _clean_positive_int(data.get(no_handling_field_name)) if show_no_handling_days_field else None + ) + comment = str(data.get(comment_field_name, "")).strip() if show_comment_field else "" + interval_value = ( + _clean_positive_int(data.get(interval_value_field_name)) if show_interval_field else None + ) + interval_unit = data.get(interval_unit_field_name) or CareRule.IntervalUnit.DAYS + ration = str(data.get(ration_field_name, "")).strip() if show_ration_field else "" + no_handling_during_shedding = show_shedding_no_handling_field and shedding_no_handling_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 + default_no_handling_days = ( + _get_setting_no_handling_days(setting, animal_rule) if show_no_handling_days_field else None + ) + comment = setting.comment if setting else "" + interval_value = care_rule.interval_value if care_rule else EVENT_SETTING_DEFAULT_INTERVALS.get(slug) + interval_unit = care_rule.interval_unit if care_rule else CareRule.IntervalUnit.DAYS + ration = pet.feeding_notes if pet and show_ration_field else "" + no_handling_during_shedding = _get_no_handling_during_shedding(setting, animal_rule) rows.append( { "slug": slug, "name": EVENT_SETTING_DISPLAY_NAMES.get(slug, event_type.name), "is_tracked": is_tracked, - "show_quick_action": show_quick_action, + "show_quick_action": is_tracked, "tracked_field_name": tracked_field_name, - "quick_action_field_name": quick_action_field_name, + "no_handling_field_name": no_handling_field_name, + "comment_field_name": comment_field_name, + "interval_value_field_name": interval_value_field_name, + "interval_unit_field_name": interval_unit_field_name, + "ration_field_name": ration_field_name, + "shedding_no_handling_field_name": shedding_no_handling_field_name, + "interval_units": get_care_rule_interval_unit_choices(), + "default_no_handling_days": default_no_handling_days, + "no_handling_during_shedding": no_handling_during_shedding, + "comment": comment, + "interval_value": interval_value, + "interval_unit": interval_unit, + "ration": ration, + "can_configure": can_configure, + "show_interval_field": show_interval_field, + "show_no_handling_days_field": show_no_handling_days_field, + "show_comment_field": show_comment_field, + "show_ration_field": show_ration_field, + "show_shedding_no_handling_field": show_shedding_no_handling_field, + "details": get_pet_event_setting_detail_lines( + slug=slug, + interval_value=interval_value if show_interval_field else None, + interval_unit=interval_unit, + default_no_handling_days=default_no_handling_days, + no_handling_during_shedding=no_handling_during_shedding, + ration=ration, + comment=comment, + ), } ) return rows +def get_pet_event_setting_detail_lines( + *, + slug: str, + interval_value: int | None, + interval_unit: str, + default_no_handling_days: int | None, + no_handling_during_shedding: bool, + ration: str, + comment: str, +) -> list[str]: + """Возвращает краткие строки текущих настроек события питомца.""" + details: list[str] = [] + + if interval_value: + details.append(f"Регулярность: {format_care_rule_interval(interval_value, interval_unit)}") + + if ration: + details.append(f"Рацион: {ration}") + + if default_no_handling_days: + details.append(f"Нельзя трогать: {format_days(default_no_handling_days)} по умолчанию") + + if slug == Event.EventType.SHEDDING: + details.append("Нельзя трогать во время линьки" if no_handling_during_shedding else "Можно трогать во время линьки") + + if comment: + details.append(f"Комментарий: {comment}") + + if not details: + details.append("Индивидуальные параметры настраиваются в конкретном событии") + + return details + + +def get_care_rule_interval_unit_choices() -> tuple[tuple[str, str], ...]: + """Возвращает человекочитаемые единицы интервала для формы настройки события.""" + return ( + (CareRule.IntervalUnit.DAYS, "дней"), + (CareRule.IntervalUnit.WEEKS, "недель"), + (CareRule.IntervalUnit.MONTHS, "месяцев"), + ) + + +def format_care_rule_interval(value: int, interval_unit: str) -> str: + """Возвращает человекочитаемый интервал правила ухода.""" + return f"каждые {value} {format_interval_unit(value, interval_unit)}" + + +def format_interval_unit(value: int, interval_unit: str) -> str: + """Склоняет единицу интервала в зависимости от числа.""" + if interval_unit == CareRule.IntervalUnit.WEEKS: + return _pluralize_ru(value, "неделю", "недели", "недель") + + if interval_unit == CareRule.IntervalUnit.MONTHS: + return _pluralize_ru(value, "месяц", "месяца", "месяцев") + + return _pluralize_ru(value, "день", "дня", "дней") + + +def format_days(value: int) -> str: + """Возвращает количество дней с правильной формой слова.""" + return f"{value} {_pluralize_ru(value, 'день', 'дня', 'дней')}" + + +def _pluralize_ru(value: int, one: str, few: str, many: str) -> str: + """Выбирает русскую форму существительного для числа.""" + value = abs(value) + if 11 <= value % 100 <= 14: + return many + + last_digit = value % 10 + if last_digit == 1: + return one + if 2 <= last_digit <= 4: + return few + return many + + +def _get_setting_no_handling_days( + setting: PetEventSetting | None, + animal_rule: AnimalTypeEventRule | None, +) -> int | None: + """Возвращает настройку запрета контакта с fallback на правило типа животного.""" + if setting and setting.default_no_handling_days is not None: + return setting.default_no_handling_days + + if animal_rule: + return animal_rule.default_no_handling_days + + return None + + +def _get_no_handling_during_shedding( + setting: PetEventSetting | None, + animal_rule: AnimalTypeEventRule | None, +) -> bool: + """Возвращает настройку контакта во время линьки.""" + if setting: + return setting.no_handling_during_shedding + + return bool( + animal_rule + and animal_rule.shedding_policy + in { + AnimalTypeEventRule.SheddingPolicy.HANDLING_SENSITIVE, + AnimalTypeEventRule.SheddingPolicy.CRITICAL, + } + ) + + +def _clean_positive_int(value) -> int | None: + """Преобразует значение формы в положительное число или None.""" + if value in (None, ""): + return None + + try: + parsed_value = int(value) + except (TypeError, ValueError): + return None + + if parsed_value <= 0: + return None + + return parsed_value + + def save_pet_event_settings(pet: Pet, data) -> None: """Сохраняет настройки отслеживаемых событий питомца из формы.""" if EVENT_SETTINGS_SUBMITTED_FIELD not in data: @@ -976,18 +1580,112 @@ def save_pet_event_settings(pet: Pet, data) -> None: 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 + default_no_handling_days = ( + _clean_positive_int(data.get(EVENT_SETTING_NO_HANDLING_FIELD_TEMPLATE.format(slug=slug))) + if slug in EVENT_SETTING_NO_HANDLING_DAYS_SLUGS + else None + ) + comment = ( + str(data.get(EVENT_SETTING_COMMENT_FIELD_TEMPLATE.format(slug=slug), "")).strip() + if slug in EVENT_SETTING_COMMENT_SLUGS + else "" + ) + no_handling_during_shedding = ( + EVENT_SETTING_SHEDDING_NO_HANDLING_FIELD_TEMPLATE.format(slug=slug) in data + if slug in EVENT_SETTING_SHEDDING_NO_HANDLING_SLUGS + else False + ) PetEventSetting.objects.update_or_create( pet=pet, event_type=event_type, defaults={ "is_tracked": is_tracked, - "show_quick_action": show_quick_action, + "show_quick_action": is_tracked, + "default_no_handling_days": default_no_handling_days, + "no_handling_during_shedding": no_handling_during_shedding, + "comment": comment, "source": PetEventSetting.Source.USER, }, ) + if slug in EVENT_SETTING_RATION_SLUGS: + ration_field_name = EVENT_SETTING_RATION_FIELD_TEMPLATE.format(slug=slug) + ration = str(data.get(ration_field_name, "")).strip() + if ration_field_name in data and ration != pet.feeding_notes: + pet.feeding_notes = ration + pet.save(update_fields=["feeding_notes", "updated_at"]) + + interval_value = ( + _clean_positive_int(data.get(EVENT_SETTING_INTERVAL_VALUE_FIELD_TEMPLATE.format(slug=slug))) + if is_tracked and slug in EVENT_SETTING_INTERVAL_SLUGS + else None + ) + if is_tracked and interval_value is None: + interval_value = EVENT_SETTING_DEFAULT_INTERVALS.get(slug) + + sync_care_rule_from_event_setting( + pet=pet, + event_type=event_type, + interval_value=interval_value, + interval_unit=data.get(EVENT_SETTING_INTERVAL_UNIT_FIELD_TEMPLATE.format(slug=slug)) + or CareRule.IntervalUnit.DAYS, + ) + + if not is_tracked: + cancel_planned_events_for_pet_event_type(pet=pet, event_type=event_type) + + +def sync_care_rule_from_event_setting( + *, + pet: Pet, + event_type: EventType, + interval_value: int | None, + interval_unit: str, +) -> CareRule | None: + """Сохраняет регулярность события питомца как CareRule.""" + if not event_type.can_be_recurring: + return None + + care_rule = CareRule.objects.filter( + pet=pet, + event_type=event_type, + ).first() + + if not interval_value: + if care_rule: + care_rule.is_active = False + care_rule.save(update_fields=["is_active", "updated_at"]) + return None + + if interval_unit not in CareRule.IntervalUnit.values: + interval_unit = CareRule.IntervalUnit.DAYS + + defaults = { + "interval_value": interval_value, + "interval_unit": interval_unit, + "calculate_from": CareRule.CalculateFrom.LAST_ACTUAL_EVENT, + "is_active": True, + "created_by": pet.owner, + } + + care_rule, _created = CareRule.objects.update_or_create( + pet=pet, + event_type=event_type, + defaults=defaults, + ) + + return care_rule + + +def cancel_planned_events_for_pet_event_type(*, pet: Pet, event_type: EventType) -> int: + """Отменяет будущие плановые задачи выключенного типа события.""" + return PlannedEvent.objects.filter( + pet=pet, + event_type=event_type, + status=PlannedEvent.Status.PLANNED, + ).update(status=PlannedEvent.Status.CANCELLED, updated_at=timezone.now()) + def get_repeat_comment(event: Event) -> str: """Формирует строку о следующем повторении события для показа в интерфейсе.""" @@ -1018,6 +1716,16 @@ def get_event_comment_lines(event: Event) -> list[str]: """Возвращает список строк комментария для таблиц.""" lines: list[str] = [] + if event.event_type == Event.EventType.SHEDDING: + started_at = timezone.localtime(get_event_happened_at(event)) + lines.append(f"Дата начала: {started_at:%d.%m.%Y %H:%M}") + if event.ended_at: + ended_at = timezone.localtime(event.ended_at) + lines.append(f"Дата окончания: {ended_at:%d.%m.%Y %H:%M}") + if event.comment: + lines.append(event.comment) + return lines + if event.event_type == Event.EventType.MEASUREMENT: lines.extend(get_measurement_comment_lines(event)) @@ -1063,12 +1771,43 @@ def pet_can_handle(pet: Pet) -> bool: if pet_has_active_condition(pet, PetCondition.ConditionType.NO_HANDLING): return False + if pet_is_in_shedding(pet) and pet_shedding_prevents_handling(pet): + return False + until_dt = get_pet_no_handling_until(pet) if not until_dt: return True return timezone.now() >= until_dt +def pet_shedding_prevents_handling(pet: Pet) -> bool: + """Проверяет, запрещает ли активная линька контакт с питомцем.""" + shedding_type = EventType.objects.filter(slug=Event.EventType.SHEDDING).first() + + if shedding_type: + setting = PetEventSetting.objects.filter( + pet=pet, + event_type=shedding_type, + is_tracked=True, + ).first() + if setting: + return setting.no_handling_during_shedding + + animal_rule = AnimalTypeEventRule.objects.filter( + animal_type=pet.animal_type, + event_type=shedding_type, + is_active=True, + is_available=True, + ).first() + if animal_rule: + return animal_rule.shedding_policy in { + AnimalTypeEventRule.SheddingPolicy.HANDLING_SENSITIVE, + AnimalTypeEventRule.SheddingPolicy.CRITICAL, + } + + return pet.animal_type == Pet.AnimalType.SNAKE + + def pet_is_in_shedding(pet: Pet) -> bool: """Проверяет, идёт ли у питомца линька с ещё не истекшим ограничением на контакт.""" if pet_has_active_condition(pet, PetCondition.ConditionType.SHEDDING): @@ -1120,5 +1859,6 @@ def build_event_row_context(event: Event) -> dict: """Собирает контекст для отображения одной строки события в шаблоне.""" return { "event": event, + "event_happened_at": get_event_happened_at(event), "event_comment_display": get_event_comment_display(event), } diff --git a/pets/templates/pets/event_detail.html b/pets/templates/pets/event_detail.html index ec302a3..83760f6 100644 --- a/pets/templates/pets/event_detail.html +++ b/pets/templates/pets/event_detail.html @@ -9,7 +9,7 @@

{{ event.title|default:event.get_event_type_display }}

- {{ event.pet.name }} • {{ event.event_datetime|date:"d.m.Y H:i" }} + {{ event.pet.name }} • {{ event_happened_at|date:"d.m.Y H:i" }}

@@ -24,7 +24,14 @@

Питомец: {{ event.pet.name }}
Тип события: {{ event.get_event_type_display }}
-
Дата и время: {{ event.event_datetime|date:"d.m.Y H:i" }}
+ {% if event.event_type == 'shedding' and event.ended_at %} +
+ Линька: + с {{ event_happened_at|date:"d.m.Y H:i" }} по {{ event.ended_at|date:"d.m.Y H:i" }} +
+ {% else %} +
Дата: {{ event_happened_at|date:"d.m.Y H:i" }}
+ {% endif %} {% if event.repeat_after_days %}
Повторить через: {{ event.repeat_after_days }} дн.
@@ -60,4 +67,4 @@

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/pets/templates/pets/event_form.html b/pets/templates/pets/event_form.html index b15ac78..2f829cc 100644 --- a/pets/templates/pets/event_form.html +++ b/pets/templates/pets/event_form.html @@ -33,6 +33,9 @@

{% csrf_token %} + {% if source_planned_event %} + + {% endif %} {% if form.non_field_errors %} {% for error in form.non_field_errors %} diff --git a/pets/templates/pets/event_list.html b/pets/templates/pets/event_list.html index 6867b4f..376540c 100644 --- a/pets/templates/pets/event_list.html +++ b/pets/templates/pets/event_list.html @@ -32,7 +32,7 @@

События ухода

- {% include 'pets/includes/events_table.html' with event_rows=event_rows filter_pets=filter_pets selected_pet_ids=selected_pet_ids selected_event_types=selected_event_types next_ordering=next_ordering current_ordering=current_ordering event_type_choices=view.model.EventType.choices %} + {% include 'pets/includes/events_table.html' with event_rows=event_rows show_pet_column=True show_event_actions=True filter_pets=filter_pets selected_pet_ids=selected_pet_ids selected_event_types=selected_event_types next_ordering=next_ordering current_ordering=current_ordering event_type_choices=view.model.EventType.choices pagination_page_obj=page_obj pagination_page_param='page' pagination_querystring=event_pagination_querystring %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/pets/templates/pets/includes/datetime_picker_field.html b/pets/templates/pets/includes/datetime_picker_field.html new file mode 100644 index 0000000..a99c8ca --- /dev/null +++ b/pets/templates/pets/includes/datetime_picker_field.html @@ -0,0 +1,112 @@ +
+ +
+ +
+ + +
+ + +
+
diff --git a/pets/templates/pets/includes/event_settings_table.html b/pets/templates/pets/includes/event_settings_table.html new file mode 100644 index 0000000..95a26be --- /dev/null +++ b/pets/templates/pets/includes/event_settings_table.html @@ -0,0 +1,177 @@ +
+ + +
+

Настройка событий

+

+ Отметьте события, которые хотите отслеживать. Если событие включено, оно появится в быстрых действиях питомца. +

+
+ +
+ + {% for row in event_setting_rows %} +
+
+ + +
+ +
+ {% for detail in row.details %} +
{{ detail }}
+ {% endfor %} +
+ +
+ {% if row.can_configure %} + + {% endif %} +
+ + {% if row.can_configure %} + +
+
+

{{ row.name }}

+ +
+ +
+ {% if row.show_ration_field %} +
+ + +
+ {% endif %} + + {% if row.show_interval_field %} +
+ +
+ + +
+
+ {% endif %} + + {% if row.show_no_handling_days_field %} +
+ + +
+ {% endif %} + + {% if row.show_shedding_no_handling_field %} +
+ + +
+ {% endif %} + + {% if row.show_comment_field %} +
+ + +
+ {% endif %} +
+ +
+ + +
+
+
+ {% endif %} +
+ {% endfor %} +
+
diff --git a/pets/templates/pets/includes/events_table.html b/pets/templates/pets/includes/events_table.html index f070538..65a7f42 100644 --- a/pets/templates/pets/includes/events_table.html +++ b/pets/templates/pets/includes/events_table.html @@ -1,7 +1,8 @@
- +
+ {% if show_pet_column %} + {% endif %} - + {% if show_event_actions %} + + {% endif %} @@ -101,11 +105,13 @@ {% if event_rows %} {% for row in event_rows %} + {% if show_pet_column %} + {% endif %} - + + {% if show_event_actions %} + {% endif %} {% endfor %} {% else %} - {% endif %} @@ -150,28 +163,67 @@ {% endif %} + {% if show_pet_column %} + {% endif %} - {{ row.event.event_datetime|date:"d.m.Y" }} + {{ row.event_happened_at|date:"d.m.Y" }}
{{ row.event_comment_display|linebreaksbr }}
- - Подробнее - + {% if show_event_actions %} + + {% endif %} {% endfor %} {% else %}
Событий нет
- Измени фильтры или добавь событие. + {{ empty_message|default:"Измени фильтры или добавь событие." }}
{% endif %} - \ No newline at end of file + + +{% if pagination_page_obj and pagination_page_obj.paginator.num_pages > 1 %} + +{% endif %} diff --git a/pets/templates/pets/pet_detail.html b/pets/templates/pets/pet_detail.html index c20bb4f..12e1fba 100644 --- a/pets/templates/pets/pet_detail.html +++ b/pets/templates/pets/pet_detail.html @@ -108,10 +108,328 @@

Комментарий

Ближайшие события

- {% if upcoming_tasks %} + {% if active_shedding_row or upcoming_tasks %}
    + {% if active_shedding_row %} +
  • + {{ active_shedding_row.text }} + + +
    + {% csrf_token %} +
    +

    Линька закончилась

    + +
    + +
    + {% with condition_id=active_shedding_row.condition_id|stringformat:"s" %} + {% include 'pets/includes/datetime_picker_field.html' with label='Дата начала' input_id='shedding-started-at-'|add:condition_id input_name='started_at' input_value=active_shedding_row.started_at_initial display_id='shedding-started-at-display-'|add:condition_id %} + + {% include 'pets/includes/datetime_picker_field.html' with label='Дата окончания' input_id='shedding-ended-at-'|add:condition_id input_name='ended_at' input_value=active_shedding_row.ended_at_initial display_id='shedding-ended-at-display-'|add:condition_id %} + {% endwith %} + +
    + + +
    +
    + +
    + + +
    + +
    +
  • + {% endif %} + {% for task in upcoming_tasks %} -
  • {{ task }}
  • +
  • + {{ task.text }} + {% if task.is_overdue %} + Просрочено + {% endif %} + {% if task.planned_event_id %} + + +
    + {% csrf_token %} +
    +

    {{ task.event_type_name }}

    + +
    + +
    + {% with planned_event_id=task.planned_event_id|stringformat:"s" %} + {% include 'pets/includes/datetime_picker_field.html' with label='Следующая дата' input_id='planned-event-scheduled-at-'|add:planned_event_id input_name='scheduled_at' input_value=task.scheduled_at_initial display_id='planned-event-scheduled-at-display-'|add:planned_event_id allow_future=True %} + {% endwith %} +
    + +
    + + +
    + +
    + {% endif %} + {% if task.can_complete %} + + +
    + {% csrf_token %} +
    +

    {{ task.event_type_name }}

    + +
    + +
    +
    + +
    + +
    + + +
    + + +
    +
    + +
    + + +
    +
    + +
    + + +
    + +
    + {% elif task.requires_event_form %} + + Выполнить + + {% endif %} + {% if task.planned_event_id %} +
    + {% csrf_token %} + + +
    + {% csrf_token %} + + + {% endif %} +
  • {% endfor %}
{% else %} @@ -119,6 +437,10 @@

Ближайшие события

{% endif %}
+ +
@@ -129,11 +451,68 @@

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

{% for action in quick_event_actions %} + {% if action.slug == 'shedding' %} + + {% else %} {{ action.label }} + {% endif %} {% endfor %}
+ + +
+ {% csrf_token %} +
+

Началась линька

+ +
+ +
+ {% include 'pets/includes/datetime_picker_field.html' with label='Дата начала' input_id='shedding-start-new-started-at' input_name='started_at' input_value=shedding_started_at_initial display_id='shedding-start-new-started-at-display' %} + +
+ + +
+
+ +
+ + +
+ +
@@ -147,33 +526,8 @@

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

- {% if pet.events.all %} -
-
{% if filter_pets %}
@@ -17,7 +18,7 @@ name="pet" value="{{ pet.pk }}" {% if pet.pk|stringformat:"s" in selected_pet_ids %}checked{% endif %} - onchange="this.form.submit()" + onchange="this.form.submit()" > {{ pet.name }} @@ -28,6 +29,7 @@ Питомец {% endif %}
{% if event_type_choices %} @@ -60,7 +62,7 @@ {% if next_ordering %}
- Дата и время + Дата
@@ -70,7 +72,7 @@ name="ordering" value="-event_datetime" {% if current_ordering == "-event_datetime" %}checked{% endif %} - onchange="this.form.submit()" + onchange="this.form.submit()" > Сначала новые @@ -81,19 +83,21 @@ name="ordering" value="event_datetime" {% if current_ordering == "event_datetime" %}checked{% endif %} - onchange="this.form.submit()" + onchange="this.form.submit()" > Сначала старые
{% else %} - Дата и время + Дата {% endif %}
КомментарийПодробнееДействия
{{ row.event.pet.name }} {% if row.event.event_type == 'custom' %} @@ -115,19 +121,26 @@ {% endif %} {{ row.event.event_datetime|date:"d.m.Y H:i" }}{{ row.event_happened_at|date:"d.m.Y H:i" }} {{ row.event_comment_display|linebreaksbr }} - - Подробнее - +
- Событий по выбранным фильтрам нет. + + {{ empty_message|default:"Событий по выбранным фильтрам нет." }}
- - - - - - - - - - {% for row in pet_event_rows %} - - - - - - - {% endfor %} - -
ТипДатаКомментарий
{{ row.event.get_event_type_display }}{{ row.event.event_datetime|date:"d.m.Y H:i" }}{{ row.event_comment_display|linebreaksbr }} - - Подробнее - -
-
+ {% if pet_event_rows %} + {% include 'pets/includes/events_table.html' with event_rows=pet_event_rows show_pet_column=False show_event_actions=can_edit_pet pagination_page_obj=pet_event_page_obj pagination_page_param='events_page' pagination_querystring=pet_event_pagination_querystring empty_message='Событий по этому питомцу пока нет.' %} {% else %}
Событий пока нет
@@ -187,3 +541,285 @@

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

{% endif %} {% endblock %} + +{% block scripts %} +{{ block.super }} + +{% endblock %} diff --git a/pets/templates/pets/pet_form.html b/pets/templates/pets/pet_form.html index 38a1ba4..2dc02fa 100644 --- a/pets/templates/pets/pet_form.html +++ b/pets/templates/pets/pet_form.html @@ -47,66 +47,7 @@

{% if event_setting_rows %} -
- - -

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

-
-

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

-
    -
  • - Включить — если галочка стоит, Вы сможете добавлять события этого типа и получать напоминания по ним. -
  • -
  • - Быстрая кнопка — в карточке питомца появится кнопка быстрого добавления этого события. -
  • -
  • - Свои события — это все уникальные события, которые Вы добавляете и полностью настраиваете самостоятельно, например поход к ветеринару или смена UV-лампы. -
  • -
-
- -
- {% for row in event_setting_rows %} -
-
-
{{ row.name }}
-
- -
-
- - -
- -
- - -
-
-
- {% endfor %} -
-
+ {% include 'pets/includes/event_settings_table.html' %} {% endif %}
@@ -128,22 +69,41 @@

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

{{ block.super }} {% endblock %} diff --git a/pets/urls.py b/pets/urls.py index 0aa2b5d..23f17d5 100644 --- a/pets/urls.py +++ b/pets/urls.py @@ -12,6 +12,12 @@ PetDetailView, PetListView, PetUpdateView, + PlannedEventCancelView, + PlannedEventCompleteView, + PlannedEventRescheduleView, + PlannedEventSkipView, + SheddingConditionFinishView, + SheddingConditionStartView, ) app_name = PetsConfig.name @@ -27,4 +33,10 @@ path("events//", EventDetailView.as_view(), name="event_detail"), path("events//update/", EventUpdateView.as_view(), name="event_update"), path("events//delete/", EventDeleteView.as_view(), name="event_delete"), + path("planned-events//complete/", PlannedEventCompleteView.as_view(), name="planned_event_complete"), + path("planned-events//skip/", PlannedEventSkipView.as_view(), name="planned_event_skip"), + path("planned-events//cancel/", PlannedEventCancelView.as_view(), name="planned_event_cancel"), + path("planned-events//reschedule/", PlannedEventRescheduleView.as_view(), name="planned_event_reschedule"), + path("/start-shedding/", SheddingConditionStartView.as_view(), name="shedding_condition_start"), + path("conditions//finish-shedding/", SheddingConditionFinishView.as_view(), name="shedding_condition_finish"), ] diff --git a/pets/views.py b/pets/views.py index ba8b3c7..abd6418 100644 --- a/pets/views.py +++ b/pets/views.py @@ -1,12 +1,19 @@ +from datetime import datetime + +from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, ValidationError +from django.core.paginator import Page, Paginator from django.db import transaction +from django.db.models.functions import Coalesce from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse_lazy +from django.views import View 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 users.timezone_services import get_user_local_now, get_user_tzinfo from .forms import ( CleaningEventForm, @@ -16,10 +23,18 @@ PetForm, SheddingEventForm, ) -from .models import Event, Pet +from .models import Event, Pet, PetCondition, PlannedEvent from .services import ( build_event_row_context, build_pet_card_context, + can_complete_planned_event_directly, + cancel_planned_event, + complete_planned_event, + delete_conditions_from_event, + finish_shedding_condition, + generate_next_planned_event_after_skip, + generate_next_planned_event_for_care_rule, + get_active_shedding_row, get_measurement_comment_lines, get_owner_display, get_pet_age_display, @@ -28,13 +43,37 @@ get_pet_no_handling_until, get_pet_quick_event_actions, get_pet_shedding_until, - get_upcoming_pet_tasks, + get_upcoming_pet_task_rows, pet_can_handle, pet_is_in_shedding, + reschedule_planned_event, save_pet_event_settings, + skip_planned_event, + start_shedding_condition, sync_care_rule_from_event_repeat, + sync_no_handling_condition_from_event, + sync_planned_events_after_event_change, ) +EVENTS_PAGE_SIZE = 10 + + +def get_querystring_without_page(request: HttpRequest, page_param: str) -> str: + """Возвращает параметры запроса для ссылок пагинации без текущего номера страницы.""" + query_params = request.GET.copy() + query_params.pop(page_param, None) + querystring = query_params.urlencode() + if querystring: + return f"{querystring}&" + return "" + + +def paginate_event_rows(request: HttpRequest, events, page_param: str) -> tuple[Page, list[dict]]: + """Разбивает события на страницы и собирает строки для общей таблицы событий.""" + paginator = Paginator(events, EVENTS_PAGE_SIZE) + page_obj = paginator.get_page(request.GET.get(page_param)) + return page_obj, [build_event_row_context(event) for event in page_obj.object_list] + class ModeratorAccessMixin: """Миксин с общей логикой проверки ролей и прав доступа к питомцам.""" @@ -91,7 +130,9 @@ class EventOwnerOrModeratorMixin(ModeratorAccessMixin): def get_queryset(self): """Возвращает queryset событий с учетом прав текущего пользователя.""" - qs = Event.objects.select_related("pet", "owner").order_by("-event_datetime") + qs = Event.objects.select_related("pet", "owner").annotate( + event_happened_at_sort=Coalesce("happened_at", "event_datetime") + ) if self.is_moderator(): return qs return qs.filter(owner=self.request.user) @@ -137,22 +178,285 @@ def get_context_data(self, **kwargs): """Добавляет в контекст вычисляемые данные о питомце, его статусе и последних событиях.""" context = super().get_context_data(**kwargs) pet = self.object - events = pet.events.all().order_by("-event_datetime")[:6] + events = pet.events.annotate(event_happened_at_sort=Coalesce("happened_at", "event_datetime")).order_by( + "-event_happened_at_sort", "-pk" + ) + pet_event_page_obj, pet_event_rows = paginate_event_rows(self.request, events, "events_page") context["pet_age_display"] = get_pet_age_display(pet.birth_date) context["owner_display"] = get_owner_display(pet.owner) context["is_owner"] = self.request.user.id == pet.owner_id context["can_edit_pet"] = self.can_edit_pet(pet) context["can_handle"] = pet_can_handle(pet) context["is_in_shedding"] = pet_is_in_shedding(pet) - context["upcoming_tasks"] = get_upcoming_pet_tasks(pet) + context["active_shedding_row"] = get_active_shedding_row(pet) + context["upcoming_tasks"] = get_upcoming_pet_task_rows(pet) + context["planned_event_completed_at_initial"] = get_user_local_now(self.request.user).replace( + second=0, + microsecond=0, + ).strftime("%Y-%m-%dT%H:%M") + context["shedding_started_at_initial"] = get_user_local_now(self.request.user).replace( + second=0, + microsecond=0, + ).strftime("%Y-%m-%dT%H:%M") context["is_public_view"] = not self.can_edit_pet(pet) - context["pet_event_rows"] = [build_event_row_context(event) for event in events] + context["pet_event_rows"] = pet_event_rows + context["pet_event_page_obj"] = pet_event_page_obj + context["pet_event_pagination_querystring"] = get_querystring_without_page(self.request, "events_page") 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 +class PlannedEventCompleteView(LoginRequiredMixin, ModeratorAccessMixin, View): + """Выполняет плановую задачу ухода из карточки питомца.""" + + def get_planned_event(self) -> PlannedEvent: + """Возвращает плановую задачу с проверкой доступа текущего пользователя.""" + planned_event = get_object_or_404( + PlannedEvent.objects.select_related( + "pet", + "pet__owner", + "event_type", + "care_rule", + "care_rule__event_type", + "care_rule__created_by", + ), + pk=self.kwargs["pk"], + status=PlannedEvent.Status.PLANNED, + ) + + if not self.can_edit_pet(planned_event.pet): + raise PermissionDenied + + if not can_complete_planned_event_directly(planned_event): + raise Http404("Planned event requires an event form") + + return planned_event + + def get_completed_at(self) -> datetime: + """Возвращает дату выполнения из формы в часовом поясе пользователя.""" + completed_at_value = self.request.POST.get("completed_at", "") + if not completed_at_value: + return get_user_local_now(self.request.user) + + try: + completed_at = datetime.strptime(completed_at_value, "%Y-%m-%dT%H:%M") + except ValueError: + return get_user_local_now(self.request.user) + + return completed_at.replace(tzinfo=get_user_tzinfo(self.request.user.time_zone)) + + def post(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + """Создаёт фактическое событие и планирует следующую задачу по правилу ухода.""" + planned_event = self.get_planned_event() + care_rule = planned_event.care_rule + completed_at = self.get_completed_at() + comment = request.POST.get("comment", "") + + try: + with transaction.atomic(): + complete_planned_event(planned_event, completed_at=completed_at, comment=comment) + + if care_rule: + generate_next_planned_event_for_care_rule(care_rule) + except ValidationError as exc: + messages.error(request, _get_validation_error_message(exc)) + + return redirect("pets:pet_detail", pk=planned_event.pet_id) + + +class PlannedEventRescheduleView(LoginRequiredMixin, ModeratorAccessMixin, View): + """Меняет дату ближайшей плановой задачи из карточки питомца.""" + + def get_planned_event(self) -> PlannedEvent: + """Возвращает плановую задачу с проверкой доступа текущего пользователя.""" + planned_event = get_object_or_404( + PlannedEvent.objects.select_related("pet", "pet__owner", "event_type"), + pk=self.kwargs["pk"], + status=PlannedEvent.Status.PLANNED, + ) + + if not self.can_edit_pet(planned_event.pet): + raise PermissionDenied + + return planned_event + + def post(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + """Сохраняет новую дату плановой задачи.""" + planned_event = self.get_planned_event() + + try: + scheduled_at = _parse_user_local_datetime( + request, + "scheduled_at", + fallback=planned_event.scheduled_at, + ) + reschedule_planned_event(planned_event, scheduled_at) + except ValidationError as exc: + messages.error(request, _get_validation_error_message(exc)) + + return redirect("pets:pet_detail", pk=planned_event.pet_id) + + +class PlannedEventSkipView(LoginRequiredMixin, ModeratorAccessMixin, View): + """Пропускает плановую задачу без создания фактического события.""" + + def get_planned_event(self) -> PlannedEvent: + """Возвращает плановую задачу с проверкой доступа текущего пользователя.""" + planned_event = get_object_or_404( + PlannedEvent.objects.select_related("pet", "pet__owner", "event_type", "care_rule", "created_by"), + pk=self.kwargs["pk"], + status=PlannedEvent.Status.PLANNED, + ) + + if not self.can_edit_pet(planned_event.pet): + raise PermissionDenied + + return planned_event + + def post(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + """Закрывает задачу как пропущенную и создаёт следующую по правилу ухода.""" + planned_event = self.get_planned_event() + + with transaction.atomic(): + skip_planned_event(planned_event) + generate_next_planned_event_after_skip(planned_event) + + return redirect("pets:pet_detail", pk=planned_event.pet_id) + + +class PlannedEventCancelView(LoginRequiredMixin, ModeratorAccessMixin, View): + """Отменяет плановую задачу без создания фактического события.""" + + def get_planned_event(self) -> PlannedEvent: + """Возвращает плановую задачу с проверкой доступа текущего пользователя.""" + planned_event = get_object_or_404( + PlannedEvent.objects.select_related("pet", "pet__owner"), + pk=self.kwargs["pk"], + status=PlannedEvent.Status.PLANNED, + ) + + if not self.can_edit_pet(planned_event.pet): + raise PermissionDenied + + return planned_event + + def post(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + """Закрывает задачу как отменённую.""" + planned_event = self.get_planned_event() + + cancel_planned_event(planned_event) + + return redirect("pets:pet_detail", pk=planned_event.pet_id) + + +class SheddingConditionStartView(LoginRequiredMixin, ModeratorAccessMixin, View): + """Начинает активную линьку питомца без создания факта Event.""" + + def get_pet(self) -> Pet: + """Возвращает питомца с проверкой доступа текущего пользователя.""" + pet = get_object_or_404(Pet.objects.select_related("owner"), pk=self.kwargs["pk"]) + + if not self.can_edit_pet(pet): + raise PermissionDenied + + return pet + + def post(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + """Создаёт активное состояние линьки.""" + pet = self.get_pet() + + try: + started_at = _parse_user_local_datetime( + request, + "started_at", + fallback=get_user_local_now(request.user), + ) + start_shedding_condition( + pet=pet, + started_at=started_at, + comment=request.POST.get("comment", ""), + created_by=request.user, + ) + except ValidationError as exc: + messages.error(request, _get_validation_error_message(exc)) + + return redirect("pets:pet_detail", pk=pet.pk) + + +class SheddingConditionFinishView(LoginRequiredMixin, ModeratorAccessMixin, View): + """Завершает активную линьку питомца из карточки.""" + + def get_condition(self) -> PetCondition: + """Возвращает активное состояние линьки с проверкой доступа.""" + condition = get_object_or_404( + PetCondition.objects.select_related("pet", "pet__owner", "created_by"), + pk=self.kwargs["pk"], + condition_type=PetCondition.ConditionType.SHEDDING, + is_active=True, + ) + + if not self.can_edit_pet(condition.pet): + raise PermissionDenied + + return condition + + def post(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + """Создаёт факт линьки и закрывает активное состояние.""" + condition = self.get_condition() + + try: + started_at = _parse_user_local_datetime( + request, + "started_at", + fallback=condition.started_at, + ) + ended_at = _parse_user_local_datetime( + request, + "ended_at", + fallback=get_user_local_now(request.user), + ) + finish_shedding_condition( + condition=condition, + started_at=started_at, + ended_at=ended_at, + comment=request.POST.get("comment", ""), + ) + except ValidationError as exc: + messages.error(request, _get_validation_error_message(exc)) + + return redirect("pets:pet_detail", pk=condition.pet_id) + + +def _parse_user_local_datetime( + request: HttpRequest, + field_name: str, + *, + fallback: datetime, +) -> datetime: + """Возвращает datetime из формы в часовом поясе пользователя.""" + value = request.POST.get(field_name, "") + if not value: + return fallback + + try: + parsed_value = datetime.strptime(value, "%Y-%m-%dT%H:%M") + except ValueError as exc: + raise ValidationError("Проверьте дату и время.") from exc + + return parsed_value.replace(tzinfo=get_user_tzinfo(request.user.time_zone)) + + +def _get_validation_error_message(error: ValidationError) -> str: + """Возвращает пользовательский текст ошибки валидации.""" + if hasattr(error, "message_dict"): + messages_by_field = error.message_dict.values() + return " ".join(message for messages_list in messages_by_field for message in messages_list) + + return " ".join(error.messages) + + class PublicPetCardAcceptanceMixin: """Сохраняет питомца вместе с принятием Правил публичности.""" @@ -253,6 +557,10 @@ def get_queryset(self): """Возвращает queryset питомцев с предзагруженным владельцем.""" return Pet.objects.select_related("owner") + def get_success_url(self) -> str: + """После сохранения настроек остаёмся на странице редактирования питомца.""" + return self.request.path + class PetDeleteView(LoginRequiredMixin, PetEditPermissionMixin, DeleteView): """Представление для удаления питомца.""" @@ -281,6 +589,7 @@ class EventListView(LoginRequiredMixin, EventOwnerOrModeratorMixin, ListView): model = Event template_name = "pets/event_list.html" context_object_name = "events" + paginate_by = EVENTS_PAGE_SIZE def get_queryset(self): """Фильтрует и сортирует события по параметрам запроса.""" @@ -297,10 +606,10 @@ def get_queryset(self): qs = qs.filter(event_type__in=selected_event_types) allowed_ordering = { - "event_datetime": "event_datetime", - "-event_datetime": "-event_datetime", + "event_datetime": "event_happened_at_sort", + "-event_datetime": "-event_happened_at_sort", } - qs = qs.order_by(allowed_ordering.get(ordering, "-event_datetime")) + qs = qs.order_by(allowed_ordering.get(ordering, "-event_happened_at_sort"), "-pk") return qs @@ -322,6 +631,7 @@ def get_context_data(self, **kwargs): context["next_ordering"] = next_ordering context["event_type_choices"] = Event.EventType.choices context["event_rows"] = [build_event_row_context(event) for event in context["events"]] + context["event_pagination_querystring"] = get_querystring_without_page(self.request, "page") return context @@ -335,6 +645,7 @@ class EventDetailView(LoginRequiredMixin, EventOwnerOrModeratorMixin, DetailView def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["measurement_comment_lines"] = get_measurement_comment_lines(self.object) + context["event_happened_at"] = self.object.happened_at or self.object.event_datetime return context @@ -391,6 +702,26 @@ def get_availability_for_pet(self, pet: Pet | None = None): return get_pet_event_creation_availability(selected_pet, self.event_type) + def get_source_planned_event(self) -> PlannedEvent | None: + """Возвращает плановую задачу, из которой создаётся факт события.""" + planned_event_id = self.request.POST.get("planned_event") or self.request.GET.get("planned_event") + if not planned_event_id: + return None + + planned_event = get_object_or_404( + PlannedEvent.objects.select_related("pet", "pet__owner", "event_type", "care_rule"), + pk=planned_event_id, + status=PlannedEvent.Status.PLANNED, + ) + + if not self.can_edit_pet(planned_event.pet): + raise PermissionDenied + + if planned_event.event_type.slug != self.event_type: + raise Http404("Planned event type does not match event form") + + return planned_event + def get_context_data(self, **kwargs): """Добавляет в форму мягкие предупреждения по настройкам события.""" context = super().get_context_data(**kwargs) @@ -400,6 +731,10 @@ def get_context_data(self, **kwargs): context["event_creation_warning"] = availability.warning context["event_creation_error"] = availability.error + planned_event = self.get_source_planned_event() + if planned_event: + context["source_planned_event"] = planned_event + return context def get_initial(self): @@ -417,6 +752,9 @@ def get_initial(self): pet_id = self.request.GET.get("pet") if pet_id: initial["pet"] = pet_id + planned_event = self.get_source_planned_event() + if planned_event: + initial["pet"] = planned_event.pet_id return initial def form_valid(self, form): @@ -431,6 +769,11 @@ def form_valid(self, form): form.add_error(None, availability.error) return self.form_invalid(form) + planned_event = self.get_source_planned_event() + if planned_event and form.cleaned_data["pet"].id != planned_event.pet_id: + form.add_error("pet", "Питомец должен совпадать с плановой задачей.") + return self.form_invalid(form) + repeat_after_days = form.cleaned_data.get("repeat_after_days") form.instance.owner = form.cleaned_data["pet"].owner if self.is_moderator() else self.request.user form.instance.event_type = self.event_type @@ -438,7 +781,17 @@ def form_valid(self, form): with transaction.atomic(): response = super().form_valid(form) + sync_no_handling_condition_from_event(self.object) sync_care_rule_from_event_repeat(self.object, repeat_after_days) + if planned_event: + care_rule = planned_event.care_rule + planned_event.status = PlannedEvent.Status.DONE + planned_event.actual_event = self.object + planned_event.save(update_fields=["status", "actual_event", "updated_at"]) + if care_rule: + generate_next_planned_event_for_care_rule(care_rule) + else: + sync_planned_events_after_event_change(self.object) return response @@ -472,7 +825,9 @@ def form_valid(self, form): with transaction.atomic(): response = super().form_valid(form) + sync_no_handling_condition_from_event(self.object) sync_care_rule_from_event_repeat(self.object, repeat_after_days) + sync_planned_events_after_event_change(self.object) return response @@ -483,3 +838,12 @@ class EventDeleteView(LoginRequiredMixin, EventOwnerOrModeratorMixin, DeleteView model = Event template_name = "pets/event_confirm_delete.html" success_url = reverse_lazy("pets:event_list") + + def form_valid(self, form): + """После удаления факта пересчитывает ближайшую плановую задачу.""" + event = self.get_object() + with transaction.atomic(): + delete_conditions_from_event(event) + response = super().form_valid(form) + sync_planned_events_after_event_change(event) + return response diff --git a/static/css/base.css b/static/css/base.css index 5fd399b..0aecf55 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -496,6 +496,314 @@ img { padding: 12px 18px; } +/* Диалоги */ + +.dialog-ui { + width: min(560px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + color: var(--color-text); + box-shadow: var(--shadow-lg); + overflow: auto; +} + +.dialog-ui::backdrop { + background: rgba(11, 20, 18, 0.48); +} + +.planned-event-dialog__form { + position: relative; + display: grid; + gap: var(--space-5); + padding: var(--space-6); +} + +.planned-event-dialog__header, +.planned-event-dialog__actions { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.planned-event-dialog__header { + padding-right: 40px; +} + +.planned-event-dialog__title { + margin: 0; + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); +} + +.planned-event-dialog__close { + position: absolute; + top: 14px; + right: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--color-text-muted); + font-size: 28px; + line-height: 1; + cursor: pointer; +} + +.planned-event-dialog__close:hover, +.planned-event-dialog__close:focus-visible { + background: var(--color-surface-muted); + color: var(--color-text); + outline: none; +} + +.planned-event-dialog__body { + display: grid; + gap: var(--space-4); +} + +.planned-event-dialog__value { + font-size: var(--font-size-md); +} + +.planned-event-dialog__datetime { + position: relative; +} + +.planned-event-dialog__date-control { + position: relative; +} + +.planned-event-dialog__date-control .form-control-ui { + padding-right: 52px; +} + +.planned-event-dialog__date-control .form-control-ui[aria-invalid="true"] { + border-color: var(--color-danger); + box-shadow: 0 0 0 4px rgba(184, 47, 47, 0.12); +} + +.planned-event-dialog__field-error { + margin: var(--space-2) 0 0; + color: var(--color-danger); + font-size: var(--font-size-sm); +} + +.planned-event-dialog__calendar-button { + position: absolute; + top: 50%; + right: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--color-text); + cursor: pointer; + transform: translateY(-50%); +} + +.planned-event-dialog__calendar-button:hover, +.planned-event-dialog__calendar-button:focus-visible { + background: var(--color-surface-muted); + outline: none; +} + +.planned-event-dialog__calendar-icon { + position: relative; + display: block; + width: 18px; + height: 18px; + border: 2px solid currentColor; + border-radius: 4px; +} + +.planned-event-dialog__calendar-icon::before { + content: ""; + position: absolute; + left: -2px; + right: -2px; + top: 3px; + border-top: 2px solid currentColor; +} + +.planned-event-dialog__calendar-icon::after { + content: ""; + position: absolute; + left: 4px; + right: 4px; + top: -5px; + height: 6px; + border-left: 2px solid currentColor; + border-right: 2px solid currentColor; +} + +.planned-event-picker { + box-sizing: border-box; + width: 100%; + margin-top: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + box-shadow: var(--shadow-lg); +} + +.planned-event-picker[hidden] { + display: none; +} + +.planned-event-picker:not([hidden]) { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px; + gap: var(--space-3); +} + +.planned-event-picker__calendar { + min-width: 0; +} + +.planned-event-picker__header, +.planned-event-picker__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.planned-event-picker__header { + margin-bottom: var(--space-2); +} + +.planned-event-picker__month { + font-weight: var(--font-weight-semibold); +} + +.planned-event-picker__nav { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--color-border); + border-radius: 50%; + background: var(--color-surface); + color: var(--color-text); + font-size: 20px; + line-height: 1; + cursor: pointer; +} + +.planned-event-picker__nav:hover, +.planned-event-picker__nav:focus-visible { + background: var(--color-surface-muted); + outline: none; +} + +.planned-event-picker__weekdays, +.planned-event-picker__days { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 2px; +} + +.planned-event-picker__weekdays { + margin-bottom: 4px; + color: var(--color-text-muted); + font-size: var(--font-size-xs); + text-align: center; +} + +.planned-event-picker__day { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 0; + height: 26px; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text); + font-size: var(--font-size-sm); + cursor: pointer; +} + +.planned-event-picker__day:hover, +.planned-event-picker__day:focus-visible { + border-color: var(--color-border); + background: var(--color-surface-muted); + outline: none; +} + +.planned-event-picker__day--muted { + color: var(--color-text-muted); +} + +.planned-event-picker__day--selected { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-text-inverse); +} + +.planned-event-picker__time { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-2); + margin-top: 0; +} + +.planned-event-picker__time-field { + display: grid; + gap: var(--space-2); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-soft); +} + +.planned-event-picker__time-field .form-select-ui { + min-height: 36px; + padding: 6px 10px; + font-size: var(--font-size-sm); +} + +.planned-event-picker__actions { + grid-column: 1 / -1; + justify-content: flex-end; + flex-wrap: wrap; + margin-top: 0; +} + +.planned-event-dialog__comment { + min-height: 88px; +} + +.planned-event-dialog__actions { + justify-content: flex-end; + flex-wrap: wrap; +} + +@media (max-width: 520px) { + .planned-event-picker:not([hidden]) { + grid-template-columns: 1fr; + } + + .planned-event-picker__time { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + /* Профиль */ .profile-media { @@ -613,6 +921,113 @@ img { box-shadow: 0 0 0 4px rgba(62, 107, 90, 0.12); } +.event-settings-panel { + margin-top: var(--space-6); +} + +.event-settings-panel__header { + margin-bottom: var(--space-4); +} + +.event-settings-table { + border-top: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); +} + +.event-settings-table__head, +.event-settings-row { + display: grid; + grid-template-columns: minmax(180px, 260px) minmax(220px, 1fr) auto; + gap: var(--space-4); +} + +.event-settings-table__head { + padding: var(--space-2) var(--space-3); + background: var(--color-surface-muted); + color: var(--color-text-muted); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; +} + +.event-settings-row { + align-items: start; + padding: var(--space-3); + border-top: 1px solid var(--color-border); +} + +.event-settings-row:nth-child(odd) { + background: var(--color-surface-muted); +} + +.event-settings-row--collapsed { + grid-template-columns: 1fr; +} + +.event-settings-row__toggle { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.event-settings-row__name { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text); + cursor: pointer; +} + +.event-settings-row__details { + color: var(--color-text); + font-size: var(--font-size-sm); + line-height: var(--line-height-base); +} + +.event-settings-row__actions { + justify-self: end; +} + +.event-setting-dialog__content { + position: relative; + display: grid; + gap: var(--space-4); + padding: var(--space-5); +} + +.event-setting-dialog__body { + display: grid; + gap: var(--space-3); +} + +.event-setting-dialog__inline-fields { + display: grid; + grid-template-columns: minmax(0, 1fr) 150px; + gap: var(--space-3); +} + +.event-setting-dialog__comment { + min-height: 96px; +} + +@media (max-width: 820px) { + .event-settings-table__head { + display: none; + } + + .event-settings-row { + grid-template-columns: 1fr; + gap: var(--space-2); + } + + .event-settings-row__actions { + justify-self: start; + } + + .event-setting-dialog__inline-fields { + grid-template-columns: 1fr; + } +} + .password-field { position: relative; } @@ -885,12 +1300,57 @@ img { .table-ui--events th:nth-child(4), .table-ui--events td:nth-child(4) { - width: 35%; + width: 32%; } .table-ui--events th:nth-child(5), .table-ui--events td:nth-child(5) { - width: 15%; + width: 18%; +} + +.table-ui--events-scoped th:nth-child(1), +.table-ui--events-scoped td:nth-child(1) { + width: 18%; +} + +.table-ui--events-scoped th:nth-child(2), +.table-ui--events-scoped td:nth-child(2) { + width: 18%; +} + +.table-ui--events-scoped th:nth-child(3), +.table-ui--events-scoped td:nth-child(3) { + width: 42%; +} + +.table-ui--events-scoped th:nth-child(4), +.table-ui--events-scoped td:nth-child(4) { + width: 22%; +} + +.event-table-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.pagination-ui { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: var(--space-3); + margin-top: var(--space-4); +} + +.pagination-ui__status { + color: var(--color-text-soft); + font-size: var(--font-size-sm); +} + +.pagination-ui__disabled { + opacity: 0.55; + pointer-events: none; } .table-empty-cell { diff --git a/tests/test_pets_admin.py b/tests/test_pets_admin.py new file mode 100644 index 0000000..0546629 --- /dev/null +++ b/tests/test_pets_admin.py @@ -0,0 +1,56 @@ +from datetime import timedelta + +import pytest +from django.contrib.admin.sites import AdminSite +from django.test import RequestFactory +from django.utils import timezone + +from pets.admin import EventAdmin +from pets.models import Event + + +@pytest.fixture +def event_admin() -> EventAdmin: + """Возвращает настроенную админку событий для unit-проверок.""" + return EventAdmin(Event, AdminSite()) + + +@pytest.fixture +def admin_request(user): + """Возвращает запрос к админке от авторизованного пользователя.""" + request = RequestFactory().get("/admin/pets/event/") + request.user = user + return request + + +@pytest.mark.django_db +def test_event_admin_orders_by_happened_at(admin_request, event_admin, user, pet): + """Админский список событий сортируется по дате факта с fallback на старое поле.""" + legacy_newer_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + ) + actual_newer_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CLEANING, + event_datetime=timezone.now() - timedelta(days=10), + ) + happened_at = timezone.now() - timedelta(hours=1) + Event.objects.filter(pk=actual_newer_event.pk).update(happened_at=happened_at) + + events = list(event_admin.get_queryset(admin_request)) + + assert legacy_newer_event.event_datetime > actual_newer_event.event_datetime + assert events[:2] == [actual_newer_event, legacy_newer_event] + + +@pytest.mark.django_db +def test_event_admin_happened_at_display_falls_back_to_legacy_datetime(event_admin, feeding_event): + """Колонка даты факта показывает старую дату, если новое поле ещё пустое.""" + Event.objects.filter(pk=feeding_event.pk).update(happened_at=None) + feeding_event.refresh_from_db() + + assert event_admin.event_happened_at_display(feeding_event) == feeding_event.event_datetime diff --git a/tests/test_pets_forms.py b/tests/test_pets_forms.py index c9fd069..a7699ca 100644 --- a/tests/test_pets_forms.py +++ b/tests/test_pets_forms.py @@ -2,7 +2,7 @@ from django.utils import timezone from legal.models import LegalDocumentVersion -from pets.forms import CustomEventForm, FeedingEventForm, PetForm +from pets.forms import CustomEventForm, FeedingEventForm, PetForm, SheddingEventForm def build_pet_form_data( @@ -128,6 +128,16 @@ def test_event_form_disallows_other_users_pet( assert "pet" in form.errors +@pytest.mark.django_db +def test_event_forms_use_fact_date_labels(user) -> None: + """Формы показывают дату факта события без старой технической формулировки.""" + feeding_form = FeedingEventForm(user=user, is_moderator=False) + shedding_form = SheddingEventForm(user=user, is_moderator=False) + + assert feeding_form.fields["event_datetime"].label == "Дата" + assert shedding_form.fields["event_datetime"].label == "Дата начала" + + @pytest.mark.django_db def test_custom_event_form_strips_title_and_comment( user, diff --git a/tests/test_pets_models.py b/tests/test_pets_models.py index e99912b..0cdfd03 100644 --- a/tests/test_pets_models.py +++ b/tests/test_pets_models.py @@ -155,6 +155,23 @@ def test_pet_event_setting_is_unique_per_pet_and_event_type(pet): ) +@pytest.mark.django_db +def test_pet_event_setting_stores_pet_specific_care_defaults(pet): + event_type = EventType.objects.get(slug=Event.EventType.FEEDING) + + setting = PetEventSetting.objects.create( + pet=pet, + event_type=event_type, + default_no_handling_days=3, + no_handling_during_shedding=True, + comment="Кормить вечером", + ) + + assert setting.default_no_handling_days == 3 + assert setting.no_handling_during_shedding is True + assert setting.comment == "Кормить вечером" + + @pytest.mark.django_db def test_care_rule_stores_recurrence_separately_from_event_history(user, pet): feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) @@ -568,6 +585,30 @@ def test_measurement_event_syncs_latest_pet_measurements_by_field(user, pet): assert pet.length_cm == 45 +@pytest.mark.django_db +def test_measurement_event_latest_field_uses_happened_at(user, pet): + legacy_newer_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.MEASUREMENT, + event_datetime=timezone.now() - timedelta(days=1), + weight_grams=30, + ) + actual_latest_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.MEASUREMENT, + event_datetime=timezone.now() - timedelta(days=10), + weight_grams=45, + ) + Event.objects.filter(pk=actual_latest_event.pk).update(happened_at=timezone.now()) + + latest_event = legacy_newer_event.get_latest_measurement_event_for_field("weight_grams") + + assert legacy_newer_event.event_datetime > actual_latest_event.event_datetime + assert latest_event == actual_latest_event + + @pytest.mark.django_db def test_event_pet_must_belong_to_owner(user, other_pet): event = Event( diff --git a/tests/test_pets_services.py b/tests/test_pets_services.py index f4778f9..f96f796 100644 --- a/tests/test_pets_services.py +++ b/tests/test_pets_services.py @@ -7,7 +7,7 @@ from django.core.management import call_command from django.utils import timezone -from pets.models import CareRule, Event, EventType, PetCondition, PlannedEvent +from pets.models import CareRule, Event, EventType, Pet, PetCondition, PetEventSetting, PlannedEvent from pets.services import ( _format_signed_diff, backfill_care_rules_from_repeat_events, @@ -17,6 +17,7 @@ generate_planned_events_from_care_rules, get_event_comment_display, get_event_happened_at, + get_latest_measurement_event_for_field, get_measurement_comment_lines, get_next_care_rule_datetime, get_next_repeat_datetime, @@ -24,6 +25,7 @@ get_pet_age_display, get_pet_no_handling_until, get_pet_shedding_until, + get_previous_measurement_event, get_upcoming_pet_task_items, get_upcoming_pet_tasks, pet_can_handle, @@ -158,6 +160,40 @@ def test_get_measurement_comment_lines_with_previous(user, pet): assert any("(+5 см)" in line for line in lines) +@pytest.mark.django_db +def test_get_previous_measurement_event_uses_happened_at(user, pet): + legacy_newer_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.MEASUREMENT, + event_datetime=timezone.now() - timedelta(days=1), + weight_grams=20, + ) + actual_previous_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.MEASUREMENT, + event_datetime=timezone.now() - timedelta(days=10), + weight_grams=25, + ) + current_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.MEASUREMENT, + event_datetime=timezone.now() - timedelta(days=3), + weight_grams=30, + ) + current_happened_at = timezone.now() + Event.objects.filter(pk=actual_previous_event.pk).update(happened_at=timezone.now() - timedelta(hours=1)) + Event.objects.filter(pk=current_event.pk).update(happened_at=current_happened_at) + current_event.refresh_from_db() + + previous_event = get_previous_measurement_event(current_event) + + assert legacy_newer_event.event_datetime > actual_previous_event.event_datetime + assert previous_event == actual_previous_event + + @pytest.mark.django_db def test_recalculate_pet_statuses_previews_measurement_changes(user, pet): Event.objects.create( @@ -212,6 +248,30 @@ def test_recalculate_pet_statuses_applies_latest_measurement_by_field(user, pet) assert pet.length_cm == older_length_event.length_cm +@pytest.mark.django_db +def test_latest_measurement_event_for_field_uses_happened_at(user, pet): + legacy_newer_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.MEASUREMENT, + event_datetime=timezone.now() - timedelta(days=1), + weight_grams=20, + ) + actual_latest_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.MEASUREMENT, + event_datetime=timezone.now() - timedelta(days=10), + weight_grams=30, + ) + Event.objects.filter(pk=actual_latest_event.pk).update(happened_at=timezone.now()) + + latest_event = get_latest_measurement_event_for_field(pet, "weight_grams") + + assert legacy_newer_event.event_datetime > actual_latest_event.event_datetime + assert latest_event == actual_latest_event + + @pytest.mark.django_db def test_recalculate_pet_statuses_command_does_not_write_by_default(user, pet): Event.objects.create( @@ -571,6 +631,67 @@ def test_complete_planned_event_creates_actual_event_and_marks_done(user, pet): assert planned_event.actual_event == event +@pytest.mark.django_db +def test_complete_planned_feeding_uses_default_no_handling_days_for_snake(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + + event = complete_planned_event(planned_event, completed_at=timezone.now() - timedelta(minutes=5)) + condition = PetCondition.objects.get( + source_event=event, + condition_type=PetCondition.ConditionType.NO_HANDLING, + ) + + assert event.no_handling_days == 2 + assert condition.pet == pet + assert condition.started_at == event.happened_at + assert condition.ended_at == get_no_handling_until(event) + assert pet_can_handle(pet) is False + + +@pytest.mark.django_db +def test_complete_planned_feeding_prefers_pet_event_setting_no_handling_days(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + PetEventSetting.objects.create( + pet=pet, + event_type=feeding_type, + default_no_handling_days=4, + ) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + + event = complete_planned_event(planned_event, completed_at=timezone.now() - timedelta(minutes=5)) + + assert event.no_handling_days == 4 + + +@pytest.mark.django_db +def test_complete_planned_feeding_does_not_force_no_handling_without_default(user, pet): + pet.animal_type = Pet.AnimalType.LIZARD + pet.save(update_fields=["animal_type"]) + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + + event = complete_planned_event(planned_event, completed_at=timezone.now() - timedelta(minutes=5)) + + assert event.no_handling_days is None + assert pet_can_handle(pet) is True + + @pytest.mark.django_db def test_complete_custom_planned_event_uses_task_title(user, pet): custom_type = EventType.objects.get(slug=Event.EventType.CUSTOM) @@ -737,6 +858,21 @@ def test_pet_can_handle_false_when_no_handling_is_active(shedding_event, pet): assert pet_can_handle(pet) is False +@pytest.mark.django_db +def test_pet_can_handle_false_for_snake_active_shedding_condition(user, pet): + PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.SHEDDING, + started_at=timezone.now() - timedelta(hours=1), + ended_at=None, + created_by=user, + ) + + assert pet.animal_type == Pet.AnimalType.SNAKE + assert pet_is_in_shedding(pet) is True + assert pet_can_handle(pet) is False + + @pytest.mark.django_db def test_pet_can_handle_true_when_no_handling_expired(user, pet): Event.objects.create( @@ -924,6 +1060,34 @@ def test_get_next_care_rule_datetime_uses_latest_matching_event(user, pet): assert get_next_care_rule_datetime(care_rule) == happened_at + timedelta(days=7) +@pytest.mark.django_db +def test_get_next_care_rule_datetime_orders_events_by_happened_at(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + legacy_newer_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + ) + actual_newer_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=10), + ) + happened_at = timezone.now() - timedelta(hours=12) + Event.objects.filter(pk=actual_newer_event.pk).update(happened_at=happened_at) + care_rule = CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + + assert legacy_newer_event.event_datetime > actual_newer_event.event_datetime + assert get_next_care_rule_datetime(care_rule) == happened_at + timedelta(days=7) + + @pytest.mark.django_db def test_get_upcoming_pet_tasks_prefers_care_rule_over_legacy_repeat(user, pet): feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) @@ -971,6 +1135,32 @@ def test_get_upcoming_pet_tasks_falls_back_to_legacy_repeat(user, pet): assert tasks[0].next_datetime == happened_at + timedelta(days=7) +@pytest.mark.django_db +def test_get_upcoming_pet_tasks_legacy_repeat_orders_by_happened_at(user, pet): + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=30, + ) + actual_newer_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=10), + repeat_after_days=7, + ) + happened_at = timezone.now() - timedelta(hours=12) + Event.objects.filter(pk=actual_newer_event.pk).update(happened_at=happened_at) + + tasks = get_upcoming_pet_task_items(pet) + + assert len(tasks) == 1 + assert tasks[0].event_type_slug == Event.EventType.FEEDING + assert tasks[0].next_datetime == happened_at + timedelta(days=7) + + @pytest.mark.django_db def test_get_upcoming_pet_tasks_prefers_planned_event_over_care_rule_and_legacy(user, pet): feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) @@ -1009,3 +1199,25 @@ def test_get_event_comment_display_contains_comment_parts(feeding_event): assert "Следующее кормление" in text assert "Не трогать до:" in text assert "После кормления не трогать" in text + + +@pytest.mark.django_db +def test_get_event_comment_display_for_shedding_contains_period(user, pet): + """Комментарий линьки в таблицах показывает начало, окончание и пользовательский текст.""" + started_at = timezone.now() - timedelta(days=2) + ended_at = timezone.now() - timedelta(days=1) + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.SHEDDING, + event_datetime=started_at, + happened_at=started_at, + ended_at=ended_at, + comment="Полиняла чулком", + ) + + text = get_event_comment_display(event) + + assert f"Дата начала: {timezone.localtime(started_at):%d.%m.%Y %H:%M}" in text + assert f"Дата окончания: {timezone.localtime(ended_at):%d.%m.%Y %H:%M}" in text + assert "Полиняла чулком" in text diff --git a/tests/test_pets_views.py b/tests/test_pets_views.py index 4cb6e47..de6ad79 100644 --- a/tests/test_pets_views.py +++ b/tests/test_pets_views.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from datetime import timezone as dt_timezone import pytest @@ -6,7 +6,16 @@ from django.utils import timezone from legal.models import LegalDocumentVersion, UserLegalAcceptance -from pets.models import AnimalTypeEventRule, CareRule, Event, EventType, Pet, PetEventSetting +from pets.models import ( + AnimalTypeEventRule, + CareRule, + Event, + EventType, + Pet, + PetCondition, + PetEventSetting, + PlannedEvent, +) SYSTEM_EVENT_SETTING_SLUGS = ( "feeding", @@ -45,10 +54,20 @@ def build_pet_post_data_with_event_settings( is_public: bool = False, tracked: dict[str, bool] | None = None, quick_actions: dict[str, bool] | None = None, + no_handling_days: dict[str, int] | None = None, + comments: dict[str, str] | None = None, + repeat_days: dict[str, int] | None = None, + rations: dict[str, str] | None = None, + shedding_no_handling: dict[str, bool] | None = None, ) -> dict[str, object]: """Возвращает данные формы питомца вместе с настройками событий.""" tracked = tracked or {} quick_actions = quick_actions or {} + no_handling_days = no_handling_days or {} + comments = comments or {} + repeat_days = repeat_days or {} + rations = rations or {} + shedding_no_handling = shedding_no_handling or {} data = build_pet_post_data( name=name, is_public=is_public, @@ -65,6 +84,22 @@ def build_pet_post_data_with_event_settings( if is_tracked and show_quick_action: data[f"event_setting_{slug}_show_quick_action"] = "on" + if slug in no_handling_days: + data[f"event_setting_{slug}_default_no_handling_days"] = no_handling_days[slug] + + if slug in comments: + data[f"event_setting_{slug}_comment"] = comments[slug] + + if slug in repeat_days: + data[f"event_setting_{slug}_interval_value"] = repeat_days[slug] + data[f"event_setting_{slug}_interval_unit"] = "days" + + if slug in rations: + data[f"event_setting_{slug}_ration"] = rations[slug] + + if shedding_no_handling.get(slug, False): + data[f"event_setting_{slug}_no_handling_during_shedding"] = "on" + return data @@ -154,14 +189,26 @@ def test_pet_form_shows_event_settings_block( content = response.content.decode() assert response.status_code == 200 - assert "Отслеживаемые события" in content - assert "Выберите, какие события для своего питомца Вы хотите отслеживать" in content - assert "Включить" in content - assert "если галочка стоит, Вы сможете добавлять события этого типа" in content + assert "Настройка событий" in content + assert "Отслеживаемые события" not in content + assert "Отметьте события, которые хотите отслеживать" in content + assert "Если событие включено, оно появится в быстрых действиях питомца" in content assert "Поставьте галочку, если это важно для Вас" not in content - assert "в карточке питомца появится кнопка быстрого добавления" in content - assert "поход к ветеринару или смена UV-лампы" in content + assert "Быстрая кнопка" not in content + assert "Изменить настройки" in content + assert "Повторять каждые" in content + assert "Нельзя трогать (дней)" in content + assert "Нельзя трогать во время линьки" in content + assert "Рацион" in content + assert "Комментарий" in content + assert "Сохранить" in content + assert "Готово" not in content + assert 'class="btn-ui btn-ui--primary btn-ui--sm"' in content + assert 'data-event-setting-dialog-open="event-setting-dialog-feeding"' in content + assert 'data-event-setting-dialog-open="event-setting-dialog-custom"' not in content assert 'name="event_settings_submitted"' in content + assert "каждые 30 дней" in content + assert "Дни" not in content assert "Кормление" in content assert "Линька" in content assert "Уборка" in content @@ -199,7 +246,7 @@ def test_pet_create_saves_event_settings_from_form( 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 settings_by_slug["measurement"].show_quick_action is True assert all(setting.source == PetEventSetting.Source.USER for setting in settings_by_slug.values()) @@ -223,10 +270,28 @@ def test_pet_update_saves_event_settings_and_updates_quick_buttons( quick_actions={ "cleaning": False, }, + no_handling_days={ + "feeding": 3, + }, + comments={ + "feeding": "Кормить мышью-бегунком", + "shedding": "Положить влажную салфетку", + }, + repeat_days={ + "feeding": 10, + }, + rations={ + "feeding": "Крысёныш, мышь", + }, + shedding_no_handling={ + "shedding": True, + }, ), ) assert response.status_code == 302 + assert response["Location"] == reverse("pets:pet_update", kwargs={"pk": pet.pk}) + pet.refresh_from_db() settings_by_slug = { setting.event_type.slug: setting @@ -242,16 +307,28 @@ def test_pet_update_saves_event_settings_and_updates_quick_buttons( 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["feeding"].default_no_handling_days == 3 + assert settings_by_slug["feeding"].comment == "Кормить мышью-бегунком" + assert pet.feeding_notes == "Крысёныш, мышь" assert settings_by_slug["cleaning"].is_tracked is True - assert settings_by_slug["cleaning"].show_quick_action is False + assert settings_by_slug["cleaning"].show_quick_action is True assert settings_by_slug["shedding"].is_tracked is False assert settings_by_slug["shedding"].show_quick_action is False + assert settings_by_slug["shedding"].no_handling_during_shedding is True + assert settings_by_slug["shedding"].comment == "Положить влажную салфетку" 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", + "cleaning", "measurement", ] + feeding_rule = CareRule.objects.get(pet=pet, event_type__slug=Event.EventType.FEEDING) + assert feeding_rule.interval_value == 10 + assert feeding_rule.interval_unit == CareRule.IntervalUnit.DAYS + measurement_rule = CareRule.objects.get(pet=pet, event_type__slug=Event.EventType.MEASUREMENT) + assert measurement_rule.interval_value == 30 + assert measurement_rule.interval_unit == CareRule.IntervalUnit.DAYS @pytest.mark.django_db @@ -517,6 +594,106 @@ def test_event_list_filters_by_event_type( assert all(event.event_type == Event.EventType.FEEDING for event in events) +@pytest.mark.django_db +def test_event_list_orders_by_happened_at( + auth_client, + user, + pet, +) -> None: + """Список событий сортируется по переходной дате факта с fallback на старое поле.""" + older_fact = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + ) + newer_fact = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CLEANING, + event_datetime=timezone.now() - timedelta(days=10), + ) + Event.objects.filter(pk=newer_fact.pk).update(happened_at=timezone.now() - timedelta(hours=1)) + + response = auth_client.get(reverse("pets:event_list")) + + assert response.status_code == 200 + assert list(response.context["events"])[:2] == [newer_fact, older_fact] + + +@pytest.mark.django_db +def test_event_list_uses_paginated_event_table_with_actions(auth_client, user, pet) -> None: + """Общий список событий показывает 10 последних событий и действия в общей таблице.""" + created_events = [ + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CLEANING, + event_datetime=timezone.now() - timedelta(minutes=index), + ) + for index in range(12) + ] + + response = auth_client.get(reverse("pets:event_list")) + content = response.content.decode() + + assert response.status_code == 200 + assert len(response.context["event_rows"]) == 10 + assert response.context["page_obj"].paginator.per_page == 10 + assert reverse("pets:event_update", kwargs={"pk": created_events[0].pk}) in content + assert reverse("pets:event_delete", kwargs={"pk": created_events[0].pk}) in content + + +@pytest.mark.django_db +def test_pet_detail_recent_events_use_happened_at( + auth_client, + user, + pet, +) -> None: + """Последние события в карточке питомца берутся по дате факта.""" + older_fact = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + ) + newer_fact = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CLEANING, + event_datetime=timezone.now() - timedelta(days=10), + ) + Event.objects.filter(pk=newer_fact.pk).update(happened_at=timezone.now() - timedelta(hours=1)) + + response = auth_client.get(reverse("pets:pet_detail", kwargs={"pk": pet.pk})) + + assert response.status_code == 200 + assert [row["event"] for row in response.context["pet_event_rows"][:2]] == [newer_fact, older_fact] + + +@pytest.mark.django_db +def test_pet_detail_uses_paginated_event_table_with_actions(auth_client, user, pet) -> None: + """Карточка питомца использует общую таблицу событий с пагинацией и действиями.""" + created_events = [ + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CLEANING, + event_datetime=timezone.now() - timedelta(minutes=index), + ) + for index in range(12) + ] + + response = auth_client.get(reverse("pets:pet_detail", kwargs={"pk": pet.pk})) + content = response.content.decode() + + assert response.status_code == 200 + assert len(response.context["pet_event_rows"]) == 10 + assert response.context["pet_event_page_obj"].paginator.per_page == 10 + assert reverse("pets:event_update", kwargs={"pk": created_events[0].pk}) in content + assert reverse("pets:event_delete", kwargs={"pk": created_events[0].pk}) in content + + @pytest.mark.django_db def test_event_create_for_user_uses_request_user_as_owner( auth_client, @@ -547,6 +724,12 @@ def test_event_create_for_user_uses_request_user_as_owner( assert event.pet == pet assert event.event_type == Event.EventType.FEEDING assert event.repeat_after_days is None + condition = PetCondition.objects.get( + source_event=event, + condition_type=PetCondition.ConditionType.NO_HANDLING, + ) + assert condition.pet == pet + assert condition.ended_at == event.happened_at + timedelta(days=1) care_rule = CareRule.objects.get(pet=pet, event_type__slug=Event.EventType.FEEDING) assert care_rule.interval_value == 7 @@ -569,7 +752,7 @@ def test_pet_detail_uses_legacy_quick_event_buttons_without_settings( 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:shedding_condition_start", kwargs={"pk": pet.pk}) 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 @@ -613,7 +796,57 @@ def test_pet_detail_quick_event_buttons_follow_pet_settings( quick_event_actions = response.context["quick_event_actions"] assert response.status_code == 200 - assert [action["slug"] for action in quick_event_actions] == ["feeding"] + assert [action["slug"] for action in quick_event_actions] == ["feeding", "cleaning"] + + +@pytest.mark.django_db +def test_pet_update_untracked_event_hides_upcoming_tasks( + auth_client, + user, + pet, +) -> None: + """Выключенный тип события исчезает из ближайших задач питомца.""" + cleaning_type = EventType.objects.get(slug=Event.EventType.CLEANING) + care_rule = CareRule.objects.create( + pet=pet, + event_type=cleaning_type, + interval_value=14, + interval_unit=CareRule.IntervalUnit.DAYS, + calculate_from=CareRule.CalculateFrom.LAST_PLANNED_DATE, + created_by=user, + ) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=cleaning_type, + care_rule=care_rule, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + + 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}, + ), + ) + planned_event.refresh_from_db() + care_rule.refresh_from_db() + detail_response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + content = detail_response.content.decode() + + assert response.status_code == 302 + assert planned_event.status == PlannedEvent.Status.CANCELLED + assert care_rule.is_active is False + assert "Убраться" not in content @pytest.mark.django_db @@ -679,6 +912,737 @@ def test_pet_detail_event_history_is_visible_independent_of_settings( assert response.context["quick_event_actions"] == [] +@pytest.mark.django_db +def test_pet_detail_shows_complete_button_for_directly_completable_planned_event( + auth_client, + user, + pet, +) -> None: + """Карточка питомца показывает кнопку выполнения для простой плановой задачи.""" + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + + response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + content = response.content.decode() + + assert response.status_code == 200 + assert reverse("pets:planned_event_complete", kwargs={"pk": planned_event.pk}) in content + assert 'data-planned-event-dialog-open="planned-event-complete-dialog-' in content + assert "Кормление" in content + assert "Тип события" not in content + assert "Выполнить задачу" not in content + assert "Дата события" not in content + assert "Дата" in content + assert 'name="completed_at"' in content + assert 'data-planned-event-date-error' in content + assert "Дата и время события не могут быть в будущем." in content + assert 'data-planned-event-picker-toggle' in content + assert "Сейчас" in content + assert "Сохранить" in content + assert "Изменить дату" in content + assert "Комментарий" in content + assert 'placeholder="Укажите подробности по событию"' in content + assert "Выполнить" in content + assert "Подтвердить" in content + + +@pytest.mark.django_db +def test_planned_event_reschedule_view_updates_next_date( + auth_client, + user, + pet, +) -> None: + """Дату ближайшей плановой задачи можно изменить из карточки питомца.""" + user.time_zone = "UTC+03:00" + user.save(update_fields=["time_zone"]) + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + + response = auth_client.post( + reverse( + "pets:planned_event_reschedule", + kwargs={"pk": planned_event.pk}, + ), + data={"scheduled_at": "2026-08-06T18:45"}, + ) + planned_event.refresh_from_db() + local_scheduled_at = timezone.localtime(planned_event.scheduled_at, dt_timezone(timedelta(hours=3))) + + assert response.status_code == 302 + assert local_scheduled_at.strftime("%Y-%m-%dT%H:%M") == "2026-08-06T18:45" + + +@pytest.mark.django_db +def test_pet_detail_shows_active_shedding_before_planned_events( + auth_client, + user, + pet, +) -> None: + """Активная линька отображается первой строкой в ближайших событиях.""" + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + condition = PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.SHEDDING, + started_at=timezone.now() - timedelta(days=1), + comment="Глаза помутнели", + created_by=user, + ) + + response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + content = response.content.decode() + + assert response.status_code == 200 + assert "В линьке с" in content + assert "Линька закончилась" in content + assert "Глаза помутнели" in content + assert reverse("pets:shedding_condition_finish", kwargs={"pk": condition.pk}) in content + assert content.index("В линьке с") < content.index("Покормить") + + +@pytest.mark.django_db +def test_pet_detail_starts_shedding_through_condition_dialog( + auth_client, + pet, +) -> None: + """Быстрая кнопка линьки открывает сценарий состояния, а не старое создание Event.""" + response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + content = response.content.decode() + + assert response.status_code == 200 + assert reverse("pets:shedding_condition_start", kwargs={"pk": pet.pk}) in content + assert "Началась линька" in content + assert f"{reverse('pets:event_create', kwargs={'event_type': Event.EventType.SHEDDING})}?pet={pet.pk}" not in content + + +@pytest.mark.django_db +def test_shedding_condition_start_view_creates_active_condition_without_event( + auth_client, + user, + pet, +) -> None: + """Старт линьки создаёт активное состояние без факта Event.""" + user.time_zone = "UTC+03:00" + user.save(update_fields=["time_zone"]) + + response = auth_client.post( + reverse("pets:shedding_condition_start", kwargs={"pk": pet.pk}), + data={ + "started_at": "2026-07-30T12:30", + "comment": "Глаза помутнели", + }, + ) + condition = PetCondition.objects.get(pet=pet, condition_type=PetCondition.ConditionType.SHEDDING) + + assert response.status_code == 302 + assert response.url == reverse("pets:pet_detail", kwargs={"pk": pet.pk}) + assert condition.is_active is True + assert condition.comment == "Глаза помутнели" + assert condition.created_by == user + assert Event.objects.count() == 0 + + +@pytest.mark.django_db +def test_shedding_condition_start_view_does_not_duplicate_active_shedding( + auth_client, + user, + pet, +) -> None: + """Повторный старт не создаёт вторую активную линьку.""" + PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.SHEDDING, + started_at=timezone.now() - timedelta(days=1), + created_by=user, + ) + + response = auth_client.post( + reverse("pets:shedding_condition_start", kwargs={"pk": pet.pk}), + data={ + "started_at": timezone.now().strftime("%Y-%m-%dT%H:%M"), + "comment": "Повтор", + }, + ) + + assert response.status_code == 302 + assert PetCondition.objects.filter( + pet=pet, + condition_type=PetCondition.ConditionType.SHEDDING, + is_active=True, + ).count() == 1 + assert Event.objects.count() == 0 + + +@pytest.mark.django_db +def test_shedding_condition_finish_view_creates_interval_event( + auth_client, + user, + pet, +) -> None: + """Завершение линьки закрывает состояние и создаёт событие с началом и окончанием.""" + user.time_zone = "UTC+03:00" + user.save(update_fields=["time_zone"]) + user_tz = dt_timezone(timedelta(hours=3)) + started_at = timezone.now() - timedelta(days=2) + ended_at = timezone.now() - timedelta(hours=1) + condition = PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.SHEDDING, + started_at=started_at, + comment="Началась линька", + created_by=user, + ) + + response = auth_client.post( + reverse( + "pets:shedding_condition_finish", + kwargs={"pk": condition.pk}, + ), + data={ + "started_at": timezone.localtime(started_at, user_tz).strftime("%Y-%m-%dT%H:%M"), + "ended_at": timezone.localtime(ended_at, user_tz).strftime("%Y-%m-%dT%H:%M"), + "comment": "Линька прошла полностью", + }, + ) + condition.refresh_from_db() + event = Event.objects.get(pet=pet, event_type=Event.EventType.SHEDDING) + + assert response.status_code == 302 + assert condition.is_active is False + assert condition.end_event == event + assert event.happened_at == event.event_datetime + assert event.ended_at is not None + assert timezone.localtime(event.happened_at, user_tz).strftime("%Y-%m-%dT%H:%M") == timezone.localtime( + started_at, user_tz + ).strftime("%Y-%m-%dT%H:%M") + assert timezone.localtime(event.ended_at, user_tz).strftime("%Y-%m-%dT%H:%M") == timezone.localtime( + ended_at, user_tz + ).strftime( + "%Y-%m-%dT%H:%M" + ) + assert event.comment == "Началась линька\n\nЛинька прошла полностью" + + +@pytest.mark.django_db +def test_shedding_condition_finish_view_does_not_duplicate_prefilled_comment( + auth_client, + user, + pet, +) -> None: + """Предзаполненный стартовый комментарий не дублируется при завершении линьки.""" + started_at = timezone.now() - timedelta(days=2) + ended_at = timezone.now() - timedelta(hours=1) + condition = PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.SHEDDING, + started_at=started_at, + comment="Глаза помутнели", + created_by=user, + ) + + response = auth_client.post( + reverse( + "pets:shedding_condition_finish", + kwargs={"pk": condition.pk}, + ), + data={ + "started_at": started_at.strftime("%Y-%m-%dT%H:%M"), + "ended_at": ended_at.strftime("%Y-%m-%dT%H:%M"), + "comment": "Глаза помутнели\n\nПолиняла чулком", + }, + ) + event = Event.objects.get(pet=pet, event_type=Event.EventType.SHEDDING) + + assert response.status_code == 302 + assert event.comment == "Глаза помутнели\n\nПолиняла чулком" + + +@pytest.mark.django_db +def test_event_detail_shows_shedding_period( + auth_client, + user, + pet, +) -> None: + """Детали линьки показывают период от начала до окончания.""" + started_at = timezone.now() - timedelta(days=2) + ended_at = timezone.now() - timedelta(hours=1) + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.SHEDDING, + event_datetime=started_at, + happened_at=started_at, + ended_at=ended_at, + comment="Линька прошла полностью", + ) + + event = Event.objects.get(event_type=Event.EventType.SHEDDING) + response = auth_client.get( + reverse( + "pets:event_detail", + kwargs={"pk": event.pk}, + ) + ) + content = response.content.decode() + + assert response.status_code == 200 + assert "Линька:" in content + assert " с " in content + assert " по " in content + assert "Линька прошла полностью" in content + + +@pytest.mark.django_db +def test_event_detail_uses_happened_at_for_display( + auth_client, + user, + pet, +) -> None: + """Детали события показывают новую дату факта, если она заполнена.""" + event_datetime = timezone.now() - timedelta(days=3) + happened_at = timezone.now() - timedelta(days=1) + event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CLEANING, + event_datetime=event_datetime, + ) + Event.objects.filter(pk=event.pk).update(happened_at=happened_at) + + response = auth_client.get(reverse("pets:event_detail", kwargs={"pk": event.pk})) + content = response.content.decode() + + assert response.status_code == 200 + assert timezone.localtime(happened_at).strftime("%d.%m.%Y %H:%M") in content + assert timezone.localtime(event_datetime).strftime("%d.%m.%Y %H:%M") not in content + + +@pytest.mark.django_db +def test_pet_detail_marks_overdue_planned_event( + auth_client, + user, + pet, +) -> None: + """Просроченная плановая задача явно отмечена в карточке питомца.""" + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() - timedelta(days=1), + created_by=user, + ) + + response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + content = response.content.decode() + + assert response.status_code == 200 + assert "Покормить" in content + assert "Просрочено" in content + assert reverse("pets:planned_event_skip", kwargs={"pk": planned_event.pk}) in content + assert reverse("pets:planned_event_cancel", kwargs={"pk": planned_event.pk}) in content + + +@pytest.mark.django_db +def test_event_delete_recalculates_planned_event_from_previous_actual_event( + auth_client, + user, + pet, +) -> None: + """После удаления последнего факта ближайшая плановая задача пересчитывается от предыдущего факта.""" + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + now = timezone.now().replace(microsecond=0) + previous_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_type_ref=feeding_type, + event_datetime=now - timedelta(days=20), + happened_at=now - timedelta(days=20), + ) + deleted_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_type_ref=feeding_type, + event_datetime=now, + happened_at=now, + ) + care_rule = CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=10, + interval_unit=CareRule.IntervalUnit.DAYS, + calculate_from=CareRule.CalculateFrom.LAST_ACTUAL_EVENT, + created_by=user, + ) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + care_rule=care_rule, + scheduled_at=now + timedelta(days=10), + created_by=user, + ) + PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.NO_HANDLING, + started_at=deleted_event.happened_at, + ended_at=deleted_event.happened_at + timedelta(days=2), + source_event=deleted_event, + created_by=user, + ) + + response = auth_client.post( + reverse( + "pets:event_delete", + kwargs={"pk": deleted_event.pk}, + ) + ) + planned_event.refresh_from_db() + + assert response.status_code == 302 + assert planned_event.scheduled_at == previous_event.happened_at + timedelta(days=10) + assert not PetCondition.objects.filter(source_event=deleted_event).exists() + + +@pytest.mark.django_db +def test_pet_detail_keeps_fallback_task_without_complete_button( + auth_client, + user, + pet, +) -> None: + """Fallback-задача из старого события видима, но не получает кнопку выполнения PlannedEvent.""" + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + repeat_after_days=7, + ) + + response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + content = response.content.decode() + + assert response.status_code == 200 + assert "Покормить" in content + assert "Выполнить" not in content + + +@pytest.mark.django_db +def test_pet_detail_shows_complete_button_for_measurement_planned_event( + auth_client, + user, + pet, +) -> None: + """Плановое измерение можно выполнить из карточки питомца.""" + measurement_type = EventType.objects.get(slug=Event.EventType.MEASUREMENT) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=measurement_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + + response = auth_client.get( + reverse( + "pets:pet_detail", + kwargs={"pk": pet.pk}, + ) + ) + content = response.content.decode() + + assert response.status_code == 200 + assert "Измерить" in content + assert reverse("pets:event_create", kwargs={"event_type": Event.EventType.MEASUREMENT}) in content + assert f"planned_event={planned_event.pk}" in content + assert "Выполнить" in content + + +@pytest.mark.django_db +def test_measurement_event_form_completes_planned_event_and_schedules_next( + auth_client, + user, + pet, +) -> None: + """Плановое измерение выполняется через форму события с весом или длиной.""" + measurement_type = EventType.objects.get(slug=Event.EventType.MEASUREMENT) + care_rule = CareRule.objects.create( + pet=pet, + event_type=measurement_type, + interval_value=30, + created_by=user, + ) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=measurement_type, + scheduled_at=timezone.now() + timedelta(days=1), + care_rule=care_rule, + created_by=user, + ) + happened_at = timezone.now().replace(second=0, microsecond=0) + + response = auth_client.post( + reverse("pets:event_create", kwargs={"event_type": Event.EventType.MEASUREMENT}), + data={ + "planned_event": planned_event.pk, + "pet": pet.pk, + "event_type": Event.EventType.MEASUREMENT, + "event_datetime": happened_at.strftime("%Y-%m-%dT%H:%M"), + "weight_grams": 42, + "length_cm": "", + "repeat_after_days": "", + "comment": "Плановое измерение", + }, + ) + + planned_event.refresh_from_db() + actual_event = planned_event.actual_event + next_planned_event = PlannedEvent.objects.get(status=PlannedEvent.Status.PLANNED) + + assert response.status_code == 302 + assert actual_event is not None + assert actual_event.event_type == Event.EventType.MEASUREMENT + assert actual_event.weight_grams == 42 + assert actual_event.comment == "Плановое измерение" + assert planned_event.status == PlannedEvent.Status.DONE + assert next_planned_event.care_rule == care_rule + assert next_planned_event.scheduled_at == actual_event.happened_at + timedelta(days=30) + + +@pytest.mark.django_db +def test_complete_planned_event_view_creates_event_marks_done_and_schedules_next( + auth_client, + user, + pet, +) -> None: + """Выполнение плановой задачи создаёт факт, закрывает задачу и создаёт следующую без дублей.""" + user.time_zone = "UTC+03:00" + user.save(update_fields=["time_zone"]) + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + care_rule = CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + care_rule=care_rule, + created_by=user, + ) + + response = auth_client.post( + reverse( + "pets:planned_event_complete", + kwargs={"pk": planned_event.pk}, + ), + data={ + "completed_at": "2026-07-29T14:30", + "comment": "Съел хорошо", + }, + ) + + planned_event.refresh_from_db() + actual_event = Event.objects.get() + next_planned_event = PlannedEvent.objects.get(status=PlannedEvent.Status.PLANNED) + + assert response.status_code == 302 + assert response.url == reverse("pets:pet_detail", kwargs={"pk": pet.pk}) + assert actual_event.pet == pet + assert actual_event.owner == user + assert actual_event.event_type == Event.EventType.FEEDING + assert actual_event.happened_at.astimezone(dt_timezone(timedelta(hours=3))).strftime("%Y-%m-%dT%H:%M") == ( + "2026-07-29T14:30" + ) + assert actual_event.event_datetime == actual_event.happened_at + assert actual_event.comment == "Съел хорошо" + assert actual_event.no_handling_days == 2 + assert actual_event.repeat_after_days is None + assert planned_event.status == PlannedEvent.Status.DONE + assert planned_event.actual_event == actual_event + assert next_planned_event.care_rule == care_rule + assert next_planned_event.actual_event is None + assert PlannedEvent.objects.filter(care_rule=care_rule, status=PlannedEvent.Status.PLANNED).count() == 1 + + +@pytest.mark.django_db +def test_complete_planned_event_view_shows_validation_error_for_future_date( + auth_client, + user, + pet, +) -> None: + """Будущая дата выполнения не роняет страницу и не закрывает плановую задачу.""" + user.time_zone = "UTC+03:00" + user.save(update_fields=["time_zone"]) + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + future_date = (timezone.now() + timedelta(days=1)).astimezone(dt_timezone(timedelta(hours=3))) + + response = auth_client.post( + reverse( + "pets:planned_event_complete", + kwargs={"pk": planned_event.pk}, + ), + data={ + "completed_at": future_date.strftime("%Y-%m-%dT%H:%M"), + "comment": "Дата из будущего", + }, + follow=True, + ) + + planned_event.refresh_from_db() + + assert response.status_code == 200 + assert "Дата и время события не могут быть в будущем." in response.content.decode() + assert planned_event.status == PlannedEvent.Status.PLANNED + + +@pytest.mark.django_db +def test_skip_planned_event_view_marks_skipped_and_schedules_next( + auth_client, + user, + pet, +) -> None: + """Пропуск плановой задачи закрывает её без факта и создаёт следующую по правилу.""" + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + care_rule = CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + scheduled_at = timezone.now().replace(microsecond=0) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=scheduled_at, + care_rule=care_rule, + created_by=user, + ) + + response = auth_client.post(reverse("pets:planned_event_skip", kwargs={"pk": planned_event.pk})) + + planned_event.refresh_from_db() + next_planned_event = PlannedEvent.objects.get(status=PlannedEvent.Status.PLANNED) + + assert response.status_code == 302 + assert response.url == reverse("pets:pet_detail", kwargs={"pk": pet.pk}) + assert planned_event.status == PlannedEvent.Status.SKIPPED + assert planned_event.actual_event is None + assert Event.objects.count() == 0 + assert next_planned_event.care_rule == care_rule + assert next_planned_event.scheduled_at == scheduled_at + timedelta(days=7) + + +@pytest.mark.django_db +def test_cancel_planned_event_view_marks_cancelled_without_scheduling_next( + auth_client, + user, + pet, +) -> None: + """Отмена плановой задачи закрывает её без факта и без новой задачи.""" + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + care_rule = CareRule.objects.create( + pet=pet, + event_type=feeding_type, + interval_value=7, + created_by=user, + ) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now(), + care_rule=care_rule, + created_by=user, + ) + + response = auth_client.post(reverse("pets:planned_event_cancel", kwargs={"pk": planned_event.pk})) + + planned_event.refresh_from_db() + + assert response.status_code == 302 + assert response.url == reverse("pets:pet_detail", kwargs={"pk": pet.pk}) + assert planned_event.status == PlannedEvent.Status.CANCELLED + assert planned_event.actual_event is None + assert Event.objects.count() == 0 + assert not PlannedEvent.objects.filter(status=PlannedEvent.Status.PLANNED).exists() + + +@pytest.mark.django_db +def test_complete_planned_event_view_denies_other_user( + other_auth_client, + user, + pet, +) -> None: + """Чужой пользователь не может выполнить плановую задачу питомца.""" + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + planned_event = PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() + timedelta(days=1), + created_by=user, + ) + + response = other_auth_client.post( + reverse( + "pets:planned_event_complete", + kwargs={"pk": planned_event.pk}, + ) + ) + + planned_event.refresh_from_db() + + assert response.status_code == 403 + assert planned_event.status == PlannedEvent.Status.PLANNED + assert Event.objects.count() == 0 + + @pytest.mark.django_db def test_event_create_url_works_when_quick_button_is_hidden( auth_client, diff --git a/tests/test_users_services.py b/tests/test_users_services.py index 6e504ed..ed17709 100644 --- a/tests/test_users_services.py +++ b/tests/test_users_services.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError from django.utils import timezone -from pets.models import CareRule, Event, EventType, PlannedEvent +from pets.models import CareRule, Event, EventType, PetCondition, PlannedEvent from users.services import ( build_daily_care_notification_text, build_email_confirmation_url, @@ -307,6 +307,21 @@ def test_get_pet_tasks_for_today_uses_planned_event(user, pet): assert "покормить" in tasks +@pytest.mark.django_db +def test_get_pet_tasks_for_today_includes_overdue_planned_event(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() - timedelta(days=1), + created_by=user, + ) + + tasks = get_pet_tasks_for_today(pet, user, local_now=timezone.now()) + + assert "просрочено: покормить" in tasks + + @pytest.mark.django_db def test_get_pet_tasks_for_today_falls_back_to_legacy_repeat(user, pet): Event.objects.create( @@ -322,6 +337,34 @@ def test_get_pet_tasks_for_today_falls_back_to_legacy_repeat(user, pet): assert "сделать уборку" in tasks +@pytest.mark.django_db +def test_get_pet_tasks_for_today_custom_legacy_repeat_orders_by_happened_at(user, pet): + """Кастомные повторяющиеся задачи сортируются по дате факта с fallback на старое поле.""" + local_now = timezone.now() + legacy_newer_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CUSTOM, + title="старое кастомное", + event_datetime=local_now - timedelta(days=2), + repeat_after_days=1, + ) + actual_newer_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CUSTOM, + title="актуальное кастомное", + event_datetime=local_now - timedelta(days=10), + repeat_after_days=1, + ) + Event.objects.filter(pk=actual_newer_event.pk).update(happened_at=local_now - timedelta(days=1)) + + tasks = get_pet_tasks_for_today(pet, user, local_now=local_now) + + assert tasks.index("актуальное кастомное") < tasks.index("просрочено: старое кастомное") + assert legacy_newer_event.event_datetime > actual_newer_event.event_datetime + + @pytest.mark.django_db def test_build_pet_notification_block_contains_pet_name_and_tasks(user, pet): text = build_pet_notification_block(pet, user) @@ -330,6 +373,35 @@ def test_build_pet_notification_block_contains_pet_name_and_tasks(user, pet): assert "Важные дела на сегодня" in text or "важных дел нет" in text.lower() +@pytest.mark.django_db +def test_build_pet_notification_block_shows_active_shedding_without_end_date(user, pet): + PetCondition.objects.create( + pet=pet, + condition_type=PetCondition.ConditionType.SHEDDING, + started_at=timezone.now() - timedelta(hours=1), + created_by=user, + ) + + text = build_pet_notification_block(pet, user) + + assert "В линьке" in text + + +@pytest.mark.django_db +def test_build_pet_notification_block_marks_overdue_task(user, pet): + feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) + PlannedEvent.objects.create( + pet=pet, + event_type=feeding_type, + scheduled_at=timezone.now() - timedelta(days=1), + created_by=user, + ) + + text = build_pet_notification_block(pet, user) + + assert "Просрочено: Покормить" in text + + @pytest.mark.django_db def test_build_daily_care_notification_text_contains_header_and_events_url(user, settings): settings.SITE_URL = "https://example.com" diff --git a/tests/test_users_views.py b/tests/test_users_views.py index eff9ce6..5a5b3e9 100644 --- a/tests/test_users_views.py +++ b/tests/test_users_views.py @@ -1,3 +1,4 @@ +from datetime import timedelta from unittest.mock import Mock import pytest @@ -12,6 +13,7 @@ LegalDocumentVersion, UserLegalAcceptance, ) +from pets.models import Event from users.services import email_confirmation_token_generator @@ -553,6 +555,53 @@ def test_owner_can_open_own_profile(auth_client, user): assert response.status_code == 200 +@pytest.mark.django_db +def test_profile_latest_events_order_by_happened_at(auth_client, user, pet): + legacy_newer_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.FEEDING, + event_datetime=timezone.now() - timedelta(days=1), + ) + actual_newer_event = Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CLEANING, + event_datetime=timezone.now() - timedelta(days=10), + ) + Event.objects.filter(pk=actual_newer_event.pk).update(happened_at=timezone.now() - timedelta(hours=1)) + + response = auth_client.get(reverse("users:profile_detail", kwargs={"pk": user.pk})) + + assert response.status_code == 200 + assert [row["event"] for row in response.context["profile_event_rows"][:2]] == [ + actual_newer_event, + legacy_newer_event, + ] + + +@pytest.mark.django_db +def test_profile_latest_events_are_paginated_with_actions(auth_client, user, pet): + created_events = [ + Event.objects.create( + owner=user, + pet=pet, + event_type=Event.EventType.CLEANING, + event_datetime=timezone.now() - timedelta(minutes=index), + ) + for index in range(12) + ] + + response = auth_client.get(reverse("users:profile_detail", kwargs={"pk": user.pk})) + content = response.content.decode() + + assert response.status_code == 200 + assert len(response.context["profile_event_rows"]) == 10 + assert response.context["profile_event_page_obj"].paginator.per_page == 10 + assert reverse("pets:event_update", kwargs={"pk": created_events[0].pk}) in content + assert reverse("pets:event_delete", kwargs={"pk": created_events[0].pk}) in content + + @pytest.mark.django_db def test_telegram_enable_without_linked_telegram_redirects(auth_client, user): response = auth_client.post(reverse("users:telegram_enable", kwargs={"pk": user.pk})) diff --git a/users/services.py b/users/services.py index e88e29c..e8af7e3 100644 --- a/users/services.py +++ b/users/services.py @@ -7,6 +7,7 @@ from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core.exceptions import ValidationError from django.core.mail import EmailMultiAlternatives +from django.db.models.functions import Coalesce from django.template.loader import render_to_string from django.urls import reverse from django.utils import timezone @@ -16,7 +17,13 @@ from config.url_services import build_absolute_url from pets.models import CareRule, Event, Pet -from pets.services import get_next_repeat_datetime, get_pet_shedding_until, get_upcoming_pet_task_items, pet_can_handle +from pets.services import ( + get_next_repeat_datetime, + get_pet_shedding_until, + get_upcoming_pet_task_items, + pet_can_handle, + pet_is_in_shedding, +) from users.timezone_services import get_user_local_now from .models import CustomUser @@ -339,8 +346,8 @@ def get_pet_tasks_for_today( """ Возвращает список задач на сегодня по питомцу в часовом поясе пользователя. В список всегда добавляется задача по смене воды. Дополнительно учитываются - повторяющиеся события ухода и кастомные события, срок которых наступает - в текущую локальную дату пользователя. + повторяющиеся события ухода и кастомные события, срок которых наступил + или уже просрочен в часовом поясе пользователя. """ tasks: list[str] = ["поменяй воду"] @@ -358,8 +365,11 @@ def get_pet_tasks_for_today( for task in get_upcoming_pet_task_items(pet): local_next_dt = task.next_datetime.astimezone(user_tz) - if local_next_dt.date() == today: - tasks.append(task_labels.get(task.event_type_slug, task.event_type_name.lower())) + if local_next_dt.date() <= today: + task_label = task_labels.get(task.event_type_slug, task.event_type_name.lower()) + if local_next_dt.date() < today: + task_label = f"просрочено: {task_label}" + tasks.append(task_label) custom_care_rule_exists = CareRule.objects.filter( pet=pet, @@ -370,7 +380,9 @@ def get_pet_tasks_for_today( if not custom_care_rule_exists: custom_events = pet.events.filter( event_type=Event.EventType.CUSTOM, - ).order_by("-event_datetime") + ).annotate(event_happened_at_sort=Coalesce("happened_at", "event_datetime")).order_by( + "-event_happened_at_sort", "-pk" + ) for event in custom_events: next_dt = get_next_repeat_datetime(event) @@ -378,8 +390,10 @@ def get_pet_tasks_for_today( continue local_next_dt = next_dt.astimezone(user_tz) - if local_next_dt.date() == today: + if local_next_dt.date() <= today: event_name = event.title.strip() if event.title else "другое событие" + if local_next_dt.date() < today: + event_name = f"просрочено: {event_name}" tasks.append(event_name) return tasks @@ -422,6 +436,11 @@ def _format_telegram_task(task: str) -> str: if not cleaned: return "📝 Действие" + overdue_prefix = "просрочено: " + if cleaned.lower().startswith(overdue_prefix): + overdue_task = cleaned[len(overdue_prefix) :] + return f"⚠️ Просрочено: {_capitalize_first(overdue_task)}" + mapped = TELEGRAM_TASK_LABELS.get(cleaned.lower()) if mapped: return mapped @@ -456,6 +475,8 @@ def build_pet_notification_block( if shedding_until: local_shedding_until = shedding_until.astimezone(user_tz) lines.append(f"🪶 Линька до: {local_shedding_until.strftime('%d.%m.%Y %H:%M')}") + elif pet_is_in_shedding(pet): + lines.append("🪶 В линьке") tasks = get_pet_tasks_for_today(pet, user, local_now=local_now) if tasks: diff --git a/users/templates/users/profile.html b/users/templates/users/profile.html index 0ae9302..a56122c 100644 --- a/users/templates/users/profile.html +++ b/users/templates/users/profile.html @@ -235,7 +235,7 @@

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

{% if profile_event_rows %} - {% include 'pets/includes/events_table.html' with event_rows=profile_event_rows %} + {% include 'pets/includes/events_table.html' with event_rows=profile_event_rows show_pet_column=True show_event_actions=True pagination_page_obj=profile_event_page_obj pagination_page_param='profile_events_page' pagination_querystring=profile_event_pagination_querystring %} Просмотреть все события {% else %} diff --git a/users/urls.py b/users/urls.py index 233b15a..2173c35 100644 --- a/users/urls.py +++ b/users/urls.py @@ -5,7 +5,6 @@ AccessRequiredView, CustomLoginView, CustomLogoutView, - ProfilePasswordChangeView, CustomPasswordResetCompleteView, CustomPasswordResetConfirmView, CustomPasswordResetDoneView, @@ -16,9 +15,10 @@ EmailConfirmView, ProfileDeleteView, ProfileDetailView, + ProfilePasswordChangeView, ProfileUpdateView, - RegistrationWelcomeView, RegisterView, + RegistrationWelcomeView, TelegramConnectView, TelegramDisableView, TelegramEnableView, diff --git a/users/views.py b/users/views.py index 0a83f28..423e53a 100644 --- a/users/views.py +++ b/users/views.py @@ -16,7 +16,9 @@ ) from django.core.cache import cache from django.core.exceptions import PermissionDenied +from django.core.paginator import Paginator from django.db import transaction +from django.db.models.functions import Coalesce from django.http import ( HttpRequest, HttpResponse, @@ -69,6 +71,7 @@ ) security_logger = logging.getLogger("checkarium.security") +PROFILE_EVENTS_PAGE_SIZE = 10 def get_client_ip(request: HttpRequest) -> str: @@ -486,10 +489,23 @@ def get_context_data(self, **kwargs): profile_user = self.get_object() latest_pets = profile_user.pets.select_related("owner").prefetch_related("events").order_by("-created_at")[:6] - latest_events = profile_user.events.select_related("pet", "owner").order_by("-event_datetime")[:10] + latest_events = ( + profile_user.events.select_related("pet", "owner") + .annotate(event_happened_at_sort=Coalesce("happened_at", "event_datetime")) + .order_by("-event_happened_at_sort", "-pk") + ) + profile_events_paginator = Paginator(latest_events, PROFILE_EVENTS_PAGE_SIZE) + profile_event_page_obj = profile_events_paginator.get_page(self.request.GET.get("profile_events_page")) + profile_event_query_params = self.request.GET.copy() + profile_event_query_params.pop("profile_events_page", None) + profile_event_querystring = profile_event_query_params.urlencode() + if profile_event_querystring: + profile_event_querystring = f"{profile_event_querystring}&" context["profile_pet_cards"] = [build_pet_card_context(pet, self.request.user) for pet in latest_pets] - context["profile_event_rows"] = [build_event_row_context(event) for event in latest_events] + context["profile_event_rows"] = [build_event_row_context(event) for event in profile_event_page_obj.object_list] + context["profile_event_page_obj"] = profile_event_page_obj + context["profile_event_pagination_querystring"] = profile_event_querystring context["care_notifications_time"] = ( f"{settings.CARE_NOTIFICATIONS_HOUR:02d}:" f"{settings.CARE_NOTIFICATIONS_MINUTE:02d}" )