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