From f5f6a4d44c4d7d3108a328329b992a9678854d7e Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Thu, 30 Jul 2026 11:28:04 +0300 Subject: [PATCH 01/16] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D0=B2=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D1=85=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=20=D1=83=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=20=D0=B8=D0=B7=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B8=20=D0=BF=D0=B8=D1=82=D0=BE=D0=BC=D1=86=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pets/services.py | 68 ++++++++++++ pets/templates/pets/pet_detail.html | 14 ++- pets/urls.py | 2 + pets/views.py | 51 ++++++++- tests/test_pets_views.py | 164 +++++++++++++++++++++++++++- 5 files changed, 293 insertions(+), 6 deletions(-) diff --git a/pets/services.py b/pets/services.py index 304ea94..98666e5 100644 --- a/pets/services.py +++ b/pets/services.py @@ -65,6 +65,10 @@ 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), +} @dataclass(frozen=True) @@ -108,6 +112,17 @@ class UpcomingPetTask: planned_event_id: int | None = None +@dataclass(frozen=True) +class UpcomingPetTaskRow: + """Хранит строку ближайшей задачи ухода для интерфейса питомца.""" + + label: str + next_datetime: datetime + text: str + planned_event_id: int | None = None + can_complete: bool = False + + @dataclass(frozen=True) class PlannedEventGenerationResult: """Хранит сводку генерации ближайших плановых задач из правил ухода.""" @@ -555,6 +570,37 @@ 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 can_complete_planned_event_directly(planned_event: PlannedEvent) -> bool: + """Проверяет, можно ли выполнить плановую задачу без дополнительной формы.""" + return planned_event.event_type.slug in DIRECT_COMPLETE_PLANNED_EVENT_TYPES + + def complete_planned_event( planned_event: PlannedEvent, *, @@ -840,6 +886,28 @@ def get_upcoming_pet_tasks(pet: Pet) -> list[str]: return tasks +def get_upcoming_pet_task_rows(pet: Pet) -> list[UpcomingPetTaskRow]: + """Возвращает ближайшие задачи ухода для карточки питомца.""" + rows: list[UpcomingPetTaskRow] = [] + + 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, + next_datetime=task.next_datetime, + text=f"{label} {local_next_dt.strftime('%d.%m.%Y')}", + planned_event_id=task.planned_event_id, + can_complete=bool( + task.planned_event_id and task.event_type_slug in DIRECT_COMPLETE_PLANNED_EVENT_TYPES + ), + ) + ) + + return rows + + 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} diff --git a/pets/templates/pets/pet_detail.html b/pets/templates/pets/pet_detail.html index c20bb4f..fdd25d3 100644 --- a/pets/templates/pets/pet_detail.html +++ b/pets/templates/pets/pet_detail.html @@ -111,7 +111,19 @@

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

{% if upcoming_tasks %} {% else %} diff --git a/pets/urls.py b/pets/urls.py index 0aa2b5d..e55612b 100644 --- a/pets/urls.py +++ b/pets/urls.py @@ -12,6 +12,7 @@ PetDetailView, PetListView, PetUpdateView, + PlannedEventCompleteView, ) app_name = PetsConfig.name @@ -27,4 +28,5 @@ 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"), ] diff --git a/pets/views.py b/pets/views.py index ba8b3c7..c45518a 100644 --- a/pets/views.py +++ b/pets/views.py @@ -2,7 +2,9 @@ from django.core.exceptions import PermissionDenied from django.db import transaction 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 @@ -16,10 +18,13 @@ PetForm, SheddingEventForm, ) -from .models import Event, Pet +from .models import Event, Pet, PlannedEvent from .services import ( build_event_row_context, build_pet_card_context, + can_complete_planned_event_directly, + complete_planned_event, + generate_next_planned_event_for_care_rule, get_measurement_comment_lines, get_owner_display, get_pet_age_display, @@ -28,7 +33,7 @@ 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, save_pet_event_settings, @@ -144,7 +149,7 @@ def get_context_data(self, **kwargs): 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["upcoming_tasks"] = get_upcoming_pet_task_rows(pet) context["is_public_view"] = not self.can_edit_pet(pet) context["pet_event_rows"] = [build_event_row_context(event) for event in events] context["quick_event_actions"] = get_pet_quick_event_actions(pet) @@ -153,6 +158,46 @@ def get_context_data(self, **kwargs): 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 post(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpResponse: + """Создаёт фактическое событие и планирует следующую задачу по правилу ухода.""" + planned_event = self.get_planned_event() + care_rule = planned_event.care_rule + + with transaction.atomic(): + complete_planned_event(planned_event) + + if care_rule: + generate_next_planned_event_for_care_rule(care_rule) + + return redirect("pets:pet_detail", pk=planned_event.pet_id) + + class PublicPetCardAcceptanceMixin: """Сохраняет питомца вместе с принятием Правил публичности.""" diff --git a/tests/test_pets_views.py b/tests/test_pets_views.py index 4cb6e47..f783d1d 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,7 @@ 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, PetEventSetting, PlannedEvent SYSTEM_EVENT_SETTING_SLUGS = ( "feeding", @@ -679,6 +679,166 @@ 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 "Выполнить" in content + + +@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_does_not_show_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:planned_event_complete", kwargs={"pk": planned_event.pk}) not in content + assert "Выполнить" not in content + + +@pytest.mark.django_db +def test_complete_planned_event_view_creates_event_marks_done_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, + ) + 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}, + ) + ) + + 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.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_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, From 38ef9a6cfbeeaca880f165ca5eefd7fc74e236ea Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Thu, 30 Jul 2026 16:08:53 +0300 Subject: [PATCH 02/16] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B8=20=D1=83=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pets/services.py | 2 + pets/templates/pets/pet_detail.html | 447 +++++++++++++++++++++++++++- pets/views.py | 46 ++- static/css/base.css | 307 +++++++++++++++++++ tests/test_pets_views.py | 68 ++++- 5 files changed, 856 insertions(+), 14 deletions(-) diff --git a/pets/services.py b/pets/services.py index 98666e5..160e672 100644 --- a/pets/services.py +++ b/pets/services.py @@ -117,6 +117,7 @@ class UpcomingPetTaskRow: """Хранит строку ближайшей задачи ухода для интерфейса питомца.""" label: str + event_type_name: str next_datetime: datetime text: str planned_event_id: int | None = None @@ -896,6 +897,7 @@ def get_upcoming_pet_task_rows(pet: Pet) -> list[UpcomingPetTaskRow]: rows.append( UpcomingPetTaskRow( label=label, + 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, diff --git a/pets/templates/pets/pet_detail.html b/pets/templates/pets/pet_detail.html index fdd25d3..83c2fa6 100644 --- a/pets/templates/pets/pet_detail.html +++ b/pets/templates/pets/pet_detail.html @@ -114,14 +114,171 @@

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

  • {{ task.text }} {% if task.can_complete %} -
    - {% csrf_token %} - -
    + Выполнить + + +
    + {% csrf_token %} +
    +

    {{ task.event_type_name }}

    + +
    + +
    +
    + +
    + +
    + + +
    + + +
    +
    + +
    + + +
    +
    + +
    + + +
    +
    +
    {% endif %}
  • {% endfor %} @@ -131,6 +288,10 @@

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

    {% endif %} + +
    @@ -199,3 +360,275 @@

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

    {% endif %} {% endblock %} + +{% block scripts %} +{{ block.super }} + +{% endblock %} diff --git a/pets/views.py b/pets/views.py index c45518a..8b545c1 100644 --- a/pets/views.py +++ b/pets/views.py @@ -1,5 +1,8 @@ +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.db import transaction from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect @@ -8,7 +11,7 @@ from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView from legal.services import create_public_pet_card_acceptance -from users.timezone_services import get_user_local_now +from users.timezone_services import get_user_local_now, get_user_tzinfo from .forms import ( CleaningEventForm, @@ -150,6 +153,10 @@ def get_context_data(self, **kwargs): context["can_handle"] = pet_can_handle(pet) context["is_in_shedding"] = pet_is_in_shedding(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["is_public_view"] = not self.can_edit_pet(pet) context["pet_event_rows"] = [build_event_row_context(event) for event in events] context["quick_event_actions"] = get_pet_quick_event_actions(pet) @@ -184,20 +191,47 @@ def get_planned_event(self) -> PlannedEvent: 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", "") - with transaction.atomic(): - complete_planned_event(planned_event) + 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) + 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) +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: """Сохраняет питомца вместе с принятием Правил публичности.""" diff --git a/static/css/base.css b/static/css/base.css index 5fd399b..632b73b 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -496,6 +496,313 @@ img { padding: 12px 18px; } +/* Диалоги */ + +.dialog-ui { + width: min(500px, 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-4); + padding: var(--space-5); +} + +.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-3); +} + +.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 { + 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) 108px; + 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 { diff --git a/tests/test_pets_views.py b/tests/test_pets_views.py index f783d1d..ac8342c 100644 --- a/tests/test_pets_views.py +++ b/tests/test_pets_views.py @@ -704,7 +704,23 @@ def test_pet_detail_shows_complete_button_for_directly_completable_planned_event 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 "Изменить" not in content + assert "Комментарий" in content + assert 'placeholder="Укажите подробности по событию"' in content assert "Выполнить" in content + assert "Подтвердить" in content @pytest.mark.django_db @@ -771,6 +787,8 @@ def test_complete_planned_event_view_creates_event_marks_done_and_schedules_next 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, @@ -790,7 +808,11 @@ def test_complete_planned_event_view_creates_event_marks_done_and_schedules_next reverse( "pets:planned_event_complete", kwargs={"pk": planned_event.pk}, - ) + ), + data={ + "completed_at": "2026-07-29T14:30", + "comment": "Съел хорошо", + }, ) planned_event.refresh_from_db() @@ -802,6 +824,11 @@ def test_complete_planned_event_view_creates_event_marks_done_and_schedules_next 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.repeat_after_days is None assert planned_event.status == PlannedEvent.Status.DONE assert planned_event.actual_event == actual_event @@ -810,6 +837,45 @@ def test_complete_planned_event_view_creates_event_marks_done_and_schedules_next 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 + assert planned_event.actual_event is None + assert Event.objects.count() == 0 + + @pytest.mark.django_db def test_complete_planned_event_view_denies_other_user( other_auth_client, From 36687bc78670faa79bc17e22bc51125c6998c08b Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Thu, 30 Jul 2026 19:41:34 +0300 Subject: [PATCH 03/16] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B7=D0=B0=D0=BF=D1=80=D0=B5=D1=82=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B0=D0=BA=D1=82=D0=B0=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BE=D1=80=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pets/services.py | 20 +++++++++++++++++++- tests/test_pets_services.py | 36 +++++++++++++++++++++++++++++++++++- tests/test_pets_views.py | 1 + 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/pets/services.py b/pets/services.py index 160e672..802006a 100644 --- a/pets/services.py +++ b/pets/services.py @@ -602,6 +602,21 @@ 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: + """Возвращает ограничение контакта по умолчанию для плановой задачи.""" + 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, *, @@ -623,6 +638,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( @@ -635,7 +653,7 @@ 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, ) diff --git a/tests/test_pets_services.py b/tests/test_pets_services.py index f4778f9..95d6697 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, PlannedEvent from pets.services import ( _format_signed_diff, backfill_care_rules_from_repeat_events, @@ -571,6 +571,40 @@ 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)) + + assert event.no_handling_days == 2 + assert pet_can_handle(pet) is False + + +@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) diff --git a/tests/test_pets_views.py b/tests/test_pets_views.py index ac8342c..195d22c 100644 --- a/tests/test_pets_views.py +++ b/tests/test_pets_views.py @@ -829,6 +829,7 @@ def test_complete_planned_event_view_creates_event_marks_done_and_schedules_next ) 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 From e0094e0457d33f68021d9727e5fd7fc432df00fa Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Fri, 31 Jul 2026 10:02:50 +0300 Subject: [PATCH 04/16] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=BC=D0=B8=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=D0=BC=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=D0=BC=20=D0=BB=D0=B8=D0=BD=D1=8C=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pets/admin.py | 2 + .../0016_peteventsetting_comment_and_more.py | 24 + ...ventsetting_no_handling_during_shedding.py | 18 + .../0018_peteventsetting_shedding_defaults.py | 48 ++ pets/migrations/0019_event_ended_at.py | 18 + pets/models.py | 28 + pets/services.py | 572 +++++++++++++++++- pets/templates/pets/event_detail.html | 9 +- .../pets/includes/datetime_picker_field.html | 112 ++++ .../pets/includes/event_settings_table.html | 177 ++++++ pets/templates/pets/pet_detail.html | 140 ++++- pets/templates/pets/pet_form.html | 98 +-- pets/urls.py | 4 + pets/views.py | 117 +++- static/css/base.css | 118 +++- tests/test_pets_models.py | 17 + tests/test_pets_services.py | 37 +- tests/test_pets_views.py | 389 +++++++++++- 18 files changed, 1810 insertions(+), 118 deletions(-) create mode 100644 pets/migrations/0016_peteventsetting_comment_and_more.py create mode 100644 pets/migrations/0017_peteventsetting_no_handling_during_shedding.py create mode 100644 pets/migrations/0018_peteventsetting_shedding_defaults.py create mode 100644 pets/migrations/0019_event_ended_at.py create mode 100644 pets/templates/pets/includes/datetime_picker_field.html create mode 100644 pets/templates/pets/includes/event_settings_table.html diff --git a/pets/admin.py b/pets/admin.py index 531c050..15e5ad3 100644 --- a/pets/admin.py +++ b/pets/admin.py @@ -171,6 +171,8 @@ class PetEventSettingAdmin(admin.ModelAdmin): "event_type", "is_tracked", "show_quick_action", + "default_no_handling_days", + "no_handling_during_shedding", "source", "created_at", ) 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..58ca337 100644 --- a/pets/models.py +++ b/pets/models.py @@ -371,6 +371,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 +814,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 +897,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): """Сохраняет событие и обновляет параметры питомца, если это событие измерения.""" diff --git a/pets/services.py b/pets/services.py index 802006a..cd024c2 100644 --- a/pets/services.py +++ b/pets/services.py @@ -15,9 +15,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 = ( "Это событие выключено в настройках питомца. " "Вы всё ещё можете добавить его по прямой ссылке, " @@ -68,6 +97,7 @@ DIRECT_COMPLETE_PLANNED_EVENT_TYPES = { cast(str, Event.EventType.FEEDING), cast(str, Event.EventType.CLEANING), + cast(str, Event.EventType.MEASUREMENT), } @@ -121,7 +151,20 @@ class UpcomingPetTaskRow: next_datetime: datetime text: str planned_event_id: int | None = None + scheduled_at_initial: str = "" can_complete: 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 @dataclass(frozen=True) @@ -597,6 +640,112 @@ def generate_next_planned_event_for_care_rule(care_rule: CareRule) -> PlannedEve ) +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 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() + + 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=comment.strip(), + ) + + condition.started_at = started_at + condition.ended_at = ended_at + condition.is_active = False + condition.end_event = event + condition.save(update_fields=["started_at", "ended_at", "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 @@ -604,6 +753,15 @@ def can_complete_planned_event_directly(planned_event: PlannedEvent) -> bool: 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, @@ -820,6 +978,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() @@ -828,6 +987,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) @@ -845,6 +1007,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 @@ -862,6 +1027,9 @@ 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 @@ -892,6 +1060,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]: """Возвращает отсортированный список ближайших повторяющихся задач по питомцу для показа в интерфейсе.""" @@ -908,6 +1093,7 @@ def get_upcoming_pet_tasks(pet: Pet) -> list[str]: def get_upcoming_pet_task_rows(pet: Pet) -> list[UpcomingPetTaskRow]: """Возвращает ближайшие задачи ухода для карточки питомца.""" rows: list[UpcomingPetTaskRow] = [] + now = timezone.now() for task in get_upcoming_pet_task_items(pet): label = UPCOMING_TASK_LABELS.get(task.event_type_slug, task.event_type_name) @@ -919,32 +1105,43 @@ def get_upcoming_pet_task_rows(pet: Pet) -> list[UpcomingPetTaskRow]: 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 ), + is_overdue=task.next_datetime < now, ) ) return rows -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_active_shedding_row(pet: Pet) -> ActiveSheddingRow | None: + """Возвращает активную линьку питомца для блока ближайших событий.""" + condition = ( + _get_active_pet_conditions_queryset(pet, PetCondition.ConditionType.SHEDDING) + .order_by("-started_at", "-pk") + .first() + ) - event_settings = pet.event_settings.select_related("event_type").filter( - event_type__slug__in=legacy_slugs, + if not condition: + return None + + 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"), ) - if not event_settings.exists(): - return [dict(action) for action in LEGACY_QUICK_EVENT_ACTIONS] - visible_slugs = { - setting.event_type.slug - for setting in event_settings - if setting.is_tracked and setting.show_quick_action and setting.event_type.is_active - } +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] @@ -998,6 +1195,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 = { @@ -1006,6 +1205,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) @@ -1018,30 +1233,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: @@ -1064,18 +1469,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: """Формирует строку о следующем повторении события для показа в интерфейсе.""" @@ -1151,12 +1650,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): diff --git a/pets/templates/pets/event_detail.html b/pets/templates/pets/event_detail.html index ec302a3..20fa6eb 100644 --- a/pets/templates/pets/event_detail.html +++ b/pets/templates/pets/event_detail.html @@ -24,7 +24,14 @@

    Питомец: {{ event.pet.name }}
    Тип события: {{ event.get_event_type_display }}
    + {% if event.event_type == 'shedding' and event.ended_at %} +
    + Линька: + с {{ event.event_datetime|date:"d.m.Y H:i" }} по {{ event.ended_at|date:"d.m.Y H:i" }} +
    + {% else %}
    Дата и время: {{ event.event_datetime|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/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/pet_detail.html b/pets/templates/pets/pet_detail.html index 83c2fa6..8396a97 100644 --- a/pets/templates/pets/pet_detail.html +++ b/pets/templates/pets/pet_detail.html @@ -108,11 +108,127 @@

    Комментарий

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

    - {% 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.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 %}
    • {% endfor %} diff --git a/pets/views.py b/pets/views.py index c68ba3c..98315f3 100644 --- a/pets/views.py +++ b/pets/views.py @@ -577,6 +577,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) @@ -586,6 +606,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): @@ -603,6 +627,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): @@ -617,6 +644,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 @@ -626,7 +658,15 @@ def form_valid(self, form): 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) + 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 diff --git a/tests/test_pets_views.py b/tests/test_pets_views.py index f868d9e..c1a989c 100644 --- a/tests/test_pets_views.py +++ b/tests/test_pets_views.py @@ -1150,10 +1150,62 @@ def test_pet_detail_shows_complete_button_for_measurement_planned_event( assert response.status_code == 200 assert "Измерить" in content - assert reverse("pets:planned_event_complete", kwargs={"pk": planned_event.pk}) 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, From 3b3edfd8f9115f394d45a9ddfe4208cf4b695f4b Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Fri, 31 Jul 2026 11:05:37 +0300 Subject: [PATCH 07/16] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81=D0=BA?= =?UTF-8?q?=20=D0=B8=20=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D0=B7=D0=B0=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=20=D1=83=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pets/services.py | 20 ++++++++ pets/templates/pets/pet_detail.html | 22 +++++++++ pets/urls.py | 4 ++ pets/views.py | 55 ++++++++++++++++++++++ tests/test_pets_views.py | 73 ++++++++++++++++++++++++++++- 5 files changed, 173 insertions(+), 1 deletion(-) diff --git a/pets/services.py b/pets/services.py index 0ab3a71..469f414 100644 --- a/pets/services.py +++ b/pets/services.py @@ -880,6 +880,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() diff --git a/pets/templates/pets/pet_detail.html b/pets/templates/pets/pet_detail.html index 28ea309..9bcd7b0 100644 --- a/pets/templates/pets/pet_detail.html +++ b/pets/templates/pets/pet_detail.html @@ -407,6 +407,28 @@

      {{ task.event_type_name }}

      Выполнить {% endif %} + {% if task.planned_event_id %} +
      + {% csrf_token %} + +
      +
      + {% csrf_token %} + +
      + {% endif %} {% endfor %}
    diff --git a/pets/urls.py b/pets/urls.py index 419cd28..0320c06 100644 --- a/pets/urls.py +++ b/pets/urls.py @@ -12,8 +12,10 @@ PetDetailView, PetListView, PetUpdateView, + PlannedEventCancelView, PlannedEventCompleteView, PlannedEventRescheduleView, + PlannedEventSkipView, SheddingConditionFinishView, ) @@ -31,6 +33,8 @@ 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("conditions//finish-shedding/", SheddingConditionFinishView.as_view(), name="shedding_condition_finish"), ] diff --git a/pets/views.py b/pets/views.py index 98315f3..fe84c3b 100644 --- a/pets/views.py +++ b/pets/views.py @@ -26,9 +26,11 @@ 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, @@ -44,6 +46,7 @@ pet_is_in_shedding, reschedule_planned_event, save_pet_event_settings, + skip_planned_event, sync_care_rule_from_event_repeat, sync_no_handling_condition_from_event, sync_planned_events_after_event_change, @@ -263,6 +266,58 @@ def post(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpRes 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 SheddingConditionFinishView(LoginRequiredMixin, ModeratorAccessMixin, View): """Завершает активную линьку питомца из карточки.""" diff --git a/tests/test_pets_views.py b/tests/test_pets_views.py index c1a989c..4b571e7 100644 --- a/tests/test_pets_views.py +++ b/tests/test_pets_views.py @@ -1015,7 +1015,7 @@ def test_pet_detail_marks_overdue_planned_event( ) -> None: """Просроченная плановая задача явно отмечена в карточке питомца.""" feeding_type = EventType.objects.get(slug=Event.EventType.FEEDING) - PlannedEvent.objects.create( + planned_event = PlannedEvent.objects.create( pet=pet, event_type=feeding_type, scheduled_at=timezone.now() - timedelta(days=1), @@ -1033,6 +1033,8 @@ def test_pet_detail_marks_overdue_planned_event( 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 @@ -1299,8 +1301,77 @@ def test_complete_planned_event_view_shows_validation_error_for_future_date( 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 From 9152cd8f233dedad428a6e8cb150d8974ad132df Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Fri, 31 Jul 2026 13:11:18 +0300 Subject: [PATCH 08/16] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D1=81=D1=82=D0=B0=D1=80=D1=82=20=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D1=8C=D0=BA=D0=B8=20=D0=BA=D0=B0=D0=BA=20=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D0=B5=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=8F=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=B8=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D1=86=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pets/services.py | 37 ++++++++- pets/templates/pets/pet_detail.html | 59 +++++++++++++- pets/urls.py | 2 + pets/views.py | 39 +++++++++ tests/test_pets_views.py | 119 +++++++++++++++++++++++++++- 5 files changed, 251 insertions(+), 5 deletions(-) diff --git a/pets/services.py b/pets/services.py index 469f414..2299e20 100644 --- a/pets/services.py +++ b/pets/services.py @@ -169,6 +169,7 @@ class ActiveSheddingRow: text: str started_at_initial: str ended_at_initial: str + comment: str = "" @dataclass(frozen=True) @@ -742,6 +743,30 @@ def reschedule_planned_event(planned_event: PlannedEvent, scheduled_at: datetime 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, @@ -757,6 +782,12 @@ def finish_shedding_condition( 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( @@ -767,14 +798,15 @@ def finish_shedding_condition( event_datetime=started_at, happened_at=started_at, ended_at=ended_at, - comment=comment.strip(), + 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", "is_active", "end_event", "updated_at"]) + condition.save(update_fields=["started_at", "ended_at", "comment", "is_active", "end_event", "updated_at"]) return event @@ -1193,6 +1225,7 @@ def get_active_shedding_row(pet: Pet) -> ActiveSheddingRow | None: 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, ) diff --git a/pets/templates/pets/pet_detail.html b/pets/templates/pets/pet_detail.html index 9bcd7b0..0a51a6f 100644 --- a/pets/templates/pets/pet_detail.html +++ b/pets/templates/pets/pet_detail.html @@ -159,7 +159,7 @@

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

    class="form-textarea-ui planned-event-dialog__comment" rows="3" placeholder="Укажите подробности по событию" - > + >{{ active_shedding_row.comment }} @@ -451,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' %} + +
    + + +
    +
    + +
    + + +
    +
    +
    diff --git a/pets/urls.py b/pets/urls.py index 0320c06..23f17d5 100644 --- a/pets/urls.py +++ b/pets/urls.py @@ -17,6 +17,7 @@ PlannedEventRescheduleView, PlannedEventSkipView, SheddingConditionFinishView, + SheddingConditionStartView, ) app_name = PetsConfig.name @@ -36,5 +37,6 @@ 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 fe84c3b..9d9ad96 100644 --- a/pets/views.py +++ b/pets/views.py @@ -47,6 +47,7 @@ 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, @@ -167,6 +168,10 @@ def get_context_data(self, **kwargs): 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["quick_event_actions"] = get_pet_quick_event_actions(pet) @@ -318,6 +323,40 @@ def post(self, request: HttpRequest, *args: object, **kwargs: object) -> HttpRes 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): """Завершает активную линьку питомца из карточки.""" diff --git a/tests/test_pets_views.py b/tests/test_pets_views.py index 4b571e7..165ac41 100644 --- a/tests/test_pets_views.py +++ b/tests/test_pets_views.py @@ -652,7 +652,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 @@ -905,6 +905,7 @@ def test_pet_detail_shows_active_shedding_before_planned_events( pet=pet, condition_type=PetCondition.ConditionType.SHEDDING, started_at=timezone.now() - timedelta(days=1), + comment="Глаза помутнели", created_by=user, ) @@ -919,10 +920,89 @@ def test_pet_detail_shows_active_shedding_before_planned_events( 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, @@ -939,6 +1019,7 @@ def test_shedding_condition_finish_view_creates_interval_event( pet=pet, condition_type=PetCondition.ConditionType.SHEDDING, started_at=started_at, + comment="Началась линька", created_by=user, ) @@ -969,7 +1050,41 @@ def test_shedding_condition_finish_view_creates_interval_event( ).strftime( "%Y-%m-%dT%H:%M" ) - assert event.comment == "Линька прошла полностью" + 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 From 8bff6c63dafcfa694c0a92af78b0cfa7be043bf7 Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Fri, 31 Jul 2026 13:19:10 +0300 Subject: [PATCH 09/16] =?UTF-8?q?=D0=A1=D0=B8=D0=BD=D1=85=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?Telegram-=D0=BD=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=81=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=BC=D0=B8=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=D0=BC?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_users_services.py | 46 +++++++++++++++++++++++++++++++++++- users/services.py | 30 ++++++++++++++++++----- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/tests/test_users_services.py b/tests/test_users_services.py index 6e504ed..83c6821 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( @@ -330,6 +345,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/users/services.py b/users/services.py index e88e29c..74fab43 100644 --- a/users/services.py +++ b/users/services.py @@ -16,7 +16,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 +345,8 @@ def get_pet_tasks_for_today( """ Возвращает список задач на сегодня по питомцу в часовом поясе пользователя. В список всегда добавляется задача по смене воды. Дополнительно учитываются - повторяющиеся события ухода и кастомные события, срок которых наступает - в текущую локальную дату пользователя. + повторяющиеся события ухода и кастомные события, срок которых наступил + или уже просрочен в часовом поясе пользователя. """ tasks: list[str] = ["поменяй воду"] @@ -358,8 +364,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, @@ -378,8 +387,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 +433,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 +472,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: From 9d60331fbedcfe37a83f5bc55852c64969a3dd48 Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Fri, 31 Jul 2026 13:28:34 +0300 Subject: [PATCH 10/16] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=BE=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BD=D0=B0=20=D0=B4=D0=B0=D1=82=D1=83=20=D1=84?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pets/services.py | 1 + pets/templates/pets/event_detail.html | 6 +- .../templates/pets/includes/events_table.html | 8 +- pets/templates/pets/pet_detail.html | 2 +- pets/views.py | 16 ++-- tests/test_pets_views.py | 79 +++++++++++++++++++ 6 files changed, 99 insertions(+), 13 deletions(-) diff --git a/pets/services.py b/pets/services.py index 2299e20..213ebcc 100644 --- a/pets/services.py +++ b/pets/services.py @@ -1829,5 +1829,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 20fa6eb..edfa906 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" }}

    @@ -27,10 +27,10 @@

    {% if event.event_type == 'shedding' and event.ended_at %}
    Линька: - с {{ event.event_datetime|date:"d.m.Y H:i" }} по {{ event.ended_at|date:"d.m.Y H:i" }} + с {{ event_happened_at|date:"d.m.Y H:i" }} по {{ event.ended_at|date:"d.m.Y H:i" }}
    {% else %} -
    Дата и время: {{ event.event_datetime|date:"d.m.Y H:i" }}
    +
    Дата и время: {{ event_happened_at|date:"d.m.Y H:i" }}
    {% endif %} {% if event.repeat_after_days %} diff --git a/pets/templates/pets/includes/events_table.html b/pets/templates/pets/includes/events_table.html index f070538..66d6a78 100644 --- a/pets/templates/pets/includes/events_table.html +++ b/pets/templates/pets/includes/events_table.html @@ -60,7 +60,7 @@ {% if next_ordering %}
    - Дата и время + Дата и время
    @@ -115,7 +115,7 @@ {% 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 }} @@ -156,7 +156,7 @@
    - {{ row.event.event_datetime|date:"d.m.Y" }} + {{ row.event_happened_at|date:"d.m.Y" }}
    {{ row.event_comment_display|linebreaksbr }}
    @@ -174,4 +174,4 @@ {% endif %} - \ No newline at end of file + diff --git a/pets/templates/pets/pet_detail.html b/pets/templates/pets/pet_detail.html index 0a51a6f..c99e9b2 100644 --- a/pets/templates/pets/pet_detail.html +++ b/pets/templates/pets/pet_detail.html @@ -541,7 +541,7 @@

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

    {% for row in pet_event_rows %} {{ row.event.get_event_type_display }} - {{ 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 }} diff --git a/pets/views.py b/pets/views.py index 9d9ad96..692a19f 100644 --- a/pets/views.py +++ b/pets/views.py @@ -4,6 +4,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import PermissionDenied, ValidationError 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 @@ -109,7 +110,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) @@ -155,7 +158,9 @@ 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" + )[:6] 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 @@ -577,10 +582,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 @@ -615,6 +620,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 diff --git a/tests/test_pets_views.py b/tests/test_pets_views.py index 165ac41..9e90b9f 100644 --- a/tests/test_pets_views.py +++ b/tests/test_pets_views.py @@ -594,6 +594,60 @@ 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_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_event_create_for_user_uses_request_user_as_owner( auth_client, @@ -1122,6 +1176,31 @@ def test_event_detail_shows_shedding_period( 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, From 5a82eb161a0f7b0b5936c54a2eab431fa664ac6f Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Fri, 31 Jul 2026 13:43:56 +0300 Subject: [PATCH 11/16] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D1=8B=20=D1=80=D0=B0=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=D1=8B=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B0=D1=82=D1=83=20=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pets/models.py | 4 +- pets/services.py | 66 +++++++++++++-------- tests/test_pets_models.py | 24 ++++++++ tests/test_pets_services.py | 114 ++++++++++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 24 deletions(-) diff --git a/pets/models.py b/pets/models.py index 58ca337..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 @@ -951,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 213ebcc..e82b715 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 @@ -271,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: @@ -307,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() ) @@ -382,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 - 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") + event_happened_at = get_event_happened_at(event) + if not event_happened_at: + return None + + 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) @@ -400,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() ) @@ -488,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: @@ -976,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: @@ -1120,11 +1138,13 @@ def get_upcoming_pet_task_items(pet: Pet) -> list[UpcomingPetTask]: 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() ) diff --git a/tests/test_pets_models.py b/tests/test_pets_models.py index 3a882e7..0cdfd03 100644 --- a/tests/test_pets_models.py +++ b/tests/test_pets_models.py @@ -585,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 79da890..06a6929 100644 --- a/tests/test_pets_services.py +++ b/tests/test_pets_services.py @@ -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( @@ -1000,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) @@ -1047,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) From b7cbb7e1380a0340325672c7a5f83b88ef9bc53e Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Fri, 31 Jul 2026 13:48:48 +0300 Subject: [PATCH 12/16] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B0=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D0=B9=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B0=D1=82=D1=83=20=D1=84=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pets/admin.py | 21 ++++++++++++--- tests/test_pets_admin.py | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 tests/test_pets_admin.py diff --git a/pets/admin.py b/pets/admin.py index 15e5ad3..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 @@ -317,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", @@ -343,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 = ( ( @@ -378,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/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 From 235fb2c044e7d2b56c1ea1ee510258fd76a3d479 Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Fri, 31 Jul 2026 14:13:36 +0300 Subject: [PATCH 13/16] =?UTF-8?q?=D0=A3=D0=BD=D0=B8=D1=84=D0=B8=D1=86?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=86=D0=B0=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D0=B9=20=D1=83=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pets/services.py | 10 +++ pets/templates/pets/event_list.html | 4 +- .../templates/pets/includes/events_table.html | 82 +++++++++++++++---- pets/templates/pets/pet_detail.html | 29 +------ pets/views.py | 29 ++++++- static/css/base.css | 49 ++++++++++- tests/test_pets_services.py | 22 +++++ tests/test_pets_views.py | 46 +++++++++++ tests/test_users_views.py | 49 +++++++++++ users/templates/users/profile.html | 2 +- users/views.py | 20 ++++- 11 files changed, 291 insertions(+), 51 deletions(-) diff --git a/pets/services.py b/pets/services.py index e82b715..0df04f6 100644 --- a/pets/services.py +++ b/pets/services.py @@ -1716,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)) 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/events_table.html b/pets/templates/pets/includes/events_table.html index 66d6a78..a191b92 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_happened_at|date:"d.m.Y" }}
    {{ row.event_comment_display|linebreaksbr }}
    - - Подробнее - + {% if show_event_actions %} + + {% endif %} {% endfor %} {% else %}
    Событий нет
    - Измени фильтры или добавь событие. + {{ empty_message|default:"Измени фильтры или добавь событие." }}
    {% endif %} + +{% 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 c99e9b2..12e1fba 100644 --- a/pets/templates/pets/pet_detail.html +++ b/pets/templates/pets/pet_detail.html @@ -526,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,7 +83,7 @@ name="ordering" value="event_datetime" {% if current_ordering == "event_datetime" %}checked{% endif %} - onchange="this.form.submit()" + onchange="this.form.submit()" > Сначала старые @@ -93,7 +95,9 @@
    КомментарийПодробнееДействия
    {{ row.event.pet.name }} {% if row.event.event_type == 'custom' %} @@ -117,17 +123,24 @@ {{ 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_happened_at|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 %}
    Событий пока нет
    diff --git a/pets/views.py b/pets/views.py index 692a19f..abd6418 100644 --- a/pets/views.py +++ b/pets/views.py @@ -3,6 +3,7 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin 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 @@ -54,6 +55,25 @@ 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: """Миксин с общей логикой проверки ролей и прав доступа к питомцам.""" @@ -160,7 +180,8 @@ def get_context_data(self, **kwargs): pet = self.object events = pet.events.annotate(event_happened_at_sort=Coalesce("happened_at", "event_datetime")).order_by( "-event_happened_at_sort", "-pk" - )[:6] + ) + 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 @@ -178,7 +199,9 @@ def get_context_data(self, **kwargs): 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) @@ -566,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): """Фильтрует и сортирует события по параметрам запроса.""" @@ -607,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 diff --git a/static/css/base.css b/static/css/base.css index 3536c04..0aecf55 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -1300,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_services.py b/tests/test_pets_services.py index 06a6929..f96f796 100644 --- a/tests/test_pets_services.py +++ b/tests/test_pets_services.py @@ -1199,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 9e90b9f..de6ad79 100644 --- a/tests/test_pets_views.py +++ b/tests/test_pets_views.py @@ -621,6 +621,29 @@ def test_event_list_orders_by_happened_at( 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, @@ -648,6 +671,29 @@ def test_pet_detail_recent_events_use_happened_at( 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, 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/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/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}" ) From a64d4e385c5c5da64e187d27db479939938d9697 Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Fri, 31 Jul 2026 15:13:09 +0300 Subject: [PATCH 14/16] =?UTF-8?q?=D0=A1=D0=B8=D0=BD=D1=85=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?Telegram-=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20=D1=81=20=D0=B4?= =?UTF-8?q?=D0=B0=D1=82=D0=BE=D0=B9=20=D1=84=D0=B0=D0=BA=D1=82=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_users_services.py | 28 ++++++++++++++++++++++++++++ users/services.py | 5 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/test_users_services.py b/tests/test_users_services.py index 83c6821..ed17709 100644 --- a/tests/test_users_services.py +++ b/tests/test_users_services.py @@ -337,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) diff --git a/users/services.py b/users/services.py index 74fab43..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 @@ -379,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) From 4190b86b3414e6054d07f045a396fbf6b56d1ce2 Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Fri, 31 Jul 2026 15:26:22 +0300 Subject: [PATCH 15/16] =?UTF-8?q?=D0=A3=D1=82=D0=BE=D1=87=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=82=D1=8B=20=D1=84=D0=B0=D0=BA=D1=82=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pets/forms.py | 6 +++++- pets/templates/pets/event_detail.html | 2 +- pets/templates/pets/includes/events_table.html | 4 ++-- tests/test_pets_forms.py | 12 +++++++++++- 4 files changed, 19 insertions(+), 5 deletions(-) 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/templates/pets/event_detail.html b/pets/templates/pets/event_detail.html index edfa906..83760f6 100644 --- a/pets/templates/pets/event_detail.html +++ b/pets/templates/pets/event_detail.html @@ -30,7 +30,7 @@

    с {{ 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" }}
    +
    Дата: {{ event_happened_at|date:"d.m.Y H:i" }}
    {% endif %} {% if event.repeat_after_days %} diff --git a/pets/templates/pets/includes/events_table.html b/pets/templates/pets/includes/events_table.html index a191b92..65a7f42 100644 --- a/pets/templates/pets/includes/events_table.html +++ b/pets/templates/pets/includes/events_table.html @@ -62,7 +62,7 @@ {% if next_ordering %}
    - Дата и время + Дата
    @@ -90,7 +90,7 @@
    {% else %} - Дата и время + Дата {% endif %} 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, From 88f6d3afe9857d71c3919379497b38393c7c7707 Mon Sep 17 00:00:00 2001 From: Ekaterina Chernyshova Date: Fri, 31 Jul 2026 16:36:44 +0300 Subject: [PATCH 16/16] =?UTF-8?q?=D0=A3=D0=BF=D0=BE=D1=80=D1=8F=D0=B4?= =?UTF-8?q?=D0=BE=D1=87=D0=B5=D0=BD=D1=8B=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D1=8B=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=B8=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D1=83=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- legal/services.py | 2 +- users/urls.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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,