+ {% if is_deceased %}
+
+

+
+ {% else %}
+ {% endif %}
{{ animal.full_name }}
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_settings.html b/src/ahc/apps/animals/templates/animals/tabs/_settings.html
index ee0031e..9f71c60 100644
--- a/src/ahc/apps/animals/templates/animals/tabs/_settings.html
+++ b/src/ahc/apps/animals/templates/animals/tabs/_settings.html
@@ -23,7 +23,10 @@
Records
Danger zone
- Remove this animal from the files
+
diff --git a/src/ahc/apps/animals/tests.py b/src/ahc/apps/animals/tests.py
index 9625195..5b25c81 100644
--- a/src/ahc/apps/animals/tests.py
+++ b/src/ahc/apps/animals/tests.py
@@ -6,10 +6,13 @@
from ahc.apps.animals.models import Animal
from ahc.apps.animals.selectors import (
animals_visible_to,
+ deceased_animals_for,
is_animal_owner,
is_pinned,
recent_records_for,
user_can_access_animal,
+ user_can_modify_animal,
+ user_can_view_animal,
)
from ahc.apps.animals.services import (
add_keeper,
@@ -18,9 +21,12 @@
process_profile_image,
remove_keeper,
set_birthday,
+ set_deceased,
set_first_contact,
+ set_memorial_note,
transfer_ownership,
unpin_animal,
+ unset_deceased,
)
from ahc.apps.animals.signals import update_allowed_users
@@ -112,6 +118,7 @@ def test_keeper_can_access(self):
profile = MagicMock()
animal = MagicMock()
animal.owner = MagicMock()
+ animal.date_of_death = None # living animal — carer access applies
with patch("ahc.apps.animals.selectors.active_share_for", return_value=MagicMock()):
assert user_can_access_animal(profile, animal) is True
@@ -119,6 +126,7 @@ def test_stranger_cannot_access(self):
profile = MagicMock()
animal = MagicMock()
animal.owner = MagicMock()
+ animal.date_of_death = None # living animal — no share
with patch("ahc.apps.animals.selectors.active_share_for", return_value=None):
assert user_can_access_animal(profile, animal) is False
@@ -988,3 +996,284 @@ def test_valid_post_updates_share_scope_and_redirects(self, animal_with_share, u
assert share.allow_basic is True
assert share.allow_diet is True
assert f"/pet/{animal.id}/tab/ownership/" in response["Location"]
+
+
+@pytest.mark.unit
+class TestIsDeceasedProperty:
+ """Animal.is_deceased: reflects date_of_death presence."""
+
+ def test_false_when_no_date_of_death(self):
+ animal = Animal(full_name="Live")
+ assert animal.is_deceased is False
+
+ def test_true_when_date_of_death_set(self):
+ animal = Animal(full_name="Gone", date_of_death=date(2024, 1, 1))
+ assert animal.is_deceased is True
+
+
+@pytest.mark.unit
+class TestDeceasedPredicates:
+ """user_can_view_animal / user_can_modify_animal behaviour on deceased animals."""
+
+ def _make_animal(self, owner, deceased=False):
+ animal = MagicMock(spec=Animal)
+ animal.owner = owner
+ animal.date_of_death = date(2024, 1, 1) if deceased else None
+ return animal
+
+ def test_owner_can_view_living_animal(self):
+ profile = MagicMock()
+ animal = self._make_animal(owner=profile, deceased=False)
+ assert user_can_view_animal(profile, animal) is True
+
+ def test_owner_can_view_deceased_animal(self):
+ profile = MagicMock()
+ animal = self._make_animal(owner=profile, deceased=True)
+ assert user_can_view_animal(profile, animal) is True
+
+ def test_owner_cannot_modify_deceased_animal(self):
+ profile = MagicMock()
+ animal = self._make_animal(owner=profile, deceased=True)
+ assert user_can_modify_animal(profile, animal) is False
+
+ def test_owner_can_modify_living_animal(self):
+ profile = MagicMock()
+ animal = self._make_animal(owner=profile, deceased=False)
+ assert user_can_modify_animal(profile, animal) is True
+
+ def test_carer_cannot_view_deceased_animal(self):
+ profile = MagicMock()
+ owner = MagicMock()
+ animal = self._make_animal(owner=owner, deceased=True)
+ assert user_can_view_animal(profile, animal) is False
+
+ def test_carer_cannot_modify_deceased_animal(self):
+ profile = MagicMock()
+ owner = MagicMock()
+ animal = self._make_animal(owner=owner, deceased=True)
+ assert user_can_modify_animal(profile, animal) is False
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+class TestDeceasedSelectors:
+ """animals_visible_to / deceased_animals_for on deceased animals."""
+
+ @pytest.fixture
+ def deceased_animal(self, db, user_profile):
+ _, profile = user_profile
+ return Animal.objects.create(full_name="Passed", owner=profile, date_of_death=date(2024, 3, 15))
+
+ def test_animals_visible_to_excludes_deceased_for_owner(self, deceased_animal, user_profile):
+ _, profile = user_profile
+ assert deceased_animal not in animals_visible_to(profile)
+
+ def test_animals_visible_to_excludes_deceased_for_carer(self, deceased_animal, second_user_profile, user_profile):
+ from ahc.apps.animals.models import AnimalShare
+
+ _, carer_profile = second_user_profile
+ AnimalShare.objects.create(animal=deceased_animal, carer=carer_profile)
+ assert deceased_animal not in animals_visible_to(carer_profile)
+
+ def test_deceased_animals_for_returns_owners_deceased(self, deceased_animal, user_profile):
+ _, profile = user_profile
+ assert deceased_animal in deceased_animals_for(profile)
+
+ def test_deceased_animals_for_excludes_carers_deceased(self, deceased_animal, second_user_profile):
+ _, other_profile = second_user_profile
+ assert deceased_animal not in deceased_animals_for(other_profile)
+
+ def test_deceased_animals_for_excludes_living(self, animal, user_profile):
+ _, profile = user_profile
+ assert animal not in deceased_animals_for(profile)
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+class TestDeceasedServices:
+ """set_deceased / set_memorial_note / unset_deceased services."""
+
+ def test_set_deceased_records_date_and_note(self, animal):
+ set_deceased(animal, date_of_death=date(2024, 5, 1), memorial_note="Rest in peace")
+ animal.refresh_from_db()
+ assert animal.date_of_death == date(2024, 5, 1)
+ assert animal.memorial_note == "Rest in peace"
+ assert animal.is_deceased is True
+
+ def test_set_deceased_does_not_delete_shares(self, animal, second_user_profile):
+ from ahc.apps.animals.models import AnimalShare
+
+ _, carer_profile = second_user_profile
+ share = AnimalShare.objects.create(animal=animal, carer=carer_profile)
+ set_deceased(animal, date_of_death=date(2024, 5, 1), memorial_note=None)
+ assert AnimalShare.objects.filter(pk=share.pk).exists()
+
+ def test_unset_deceased_clears_date_and_restores_visibility(self, animal, user_profile, second_user_profile):
+ from ahc.apps.animals.models import AnimalShare
+
+ _, carer_profile = second_user_profile
+ AnimalShare.objects.create(animal=animal, carer=carer_profile)
+ set_deceased(animal, date_of_death=date(2024, 5, 1), memorial_note="Farewell")
+ assert animal not in animals_visible_to(carer_profile)
+
+ unset_deceased(animal)
+ animal.refresh_from_db()
+ assert animal.date_of_death is None
+ assert animal.memorial_note == "Farewell" # preserved after un-archive
+ assert animal in animals_visible_to(carer_profile)
+
+ def test_set_memorial_note_updates_note_only(self, animal):
+ set_deceased(animal, date_of_death=date(2024, 5, 1), memorial_note="Original")
+ set_memorial_note(animal, memorial_note="Updated")
+ animal.refresh_from_db()
+ assert animal.memorial_note == "Updated"
+ assert animal.date_of_death == date(2024, 5, 1)
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+class TestMarkDeceasedView:
+ """MarkDeceasedView: owner can archive; carer cannot."""
+
+ def _client_for(self, user):
+ from django.test import Client
+
+ c = Client()
+ c.force_login(user)
+ return c
+
+ def test_owner_get_returns_200(self, animal, user_profile):
+ user, _ = user_profile
+ response = self._client_for(user).get(f"/pet/{animal.id}/deceased/")
+ assert response.status_code == 200
+
+ def test_carer_get_returns_403(self, animal, second_user_profile):
+ other_user, _ = second_user_profile
+ response = self._client_for(other_user).get(f"/pet/{animal.id}/deceased/")
+ assert response.status_code == 403
+
+ def test_owner_post_archives_and_redirects(self, animal, user_profile):
+ user, _ = user_profile
+ response = self._client_for(user).post(
+ f"/pet/{animal.id}/deceased/",
+ {"date_of_death": "2024-04-01", "memorial_note": "Goodbye"},
+ )
+ assert response.status_code == 302
+ animal.refresh_from_db()
+ assert animal.date_of_death == date(2024, 4, 1)
+ assert animal.memorial_note == "Goodbye"
+
+ def test_future_date_rejected(self, animal, user_profile):
+ user, _ = user_profile
+ response = self._client_for(user).post(
+ f"/pet/{animal.id}/deceased/",
+ {"date_of_death": "2099-12-31"},
+ )
+ assert response.status_code == 200 # form re-render with error
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+class TestUnarchiveAnimalView:
+ """UnarchiveAnimalView: owner can un-archive; carer cannot."""
+
+ @pytest.fixture
+ def deceased_animal(self, db, user_profile):
+ _, profile = user_profile
+ return Animal.objects.create(full_name="Passed", owner=profile, date_of_death=date(2024, 3, 15))
+
+ def _client_for(self, user):
+ from django.test import Client
+
+ c = Client()
+ c.force_login(user)
+ return c
+
+ def test_owner_post_restores_animal(self, deceased_animal, user_profile):
+ user, _ = user_profile
+ response = self._client_for(user).post(f"/pet/{deceased_animal.id}/unarchive/")
+ assert response.status_code == 302
+ deceased_animal.refresh_from_db()
+ assert deceased_animal.date_of_death is None
+
+ def test_carer_post_returns_403(self, deceased_animal, second_user_profile):
+ other_user, _ = second_user_profile
+ response = self._client_for(other_user).post(f"/pet/{deceased_animal.id}/unarchive/")
+ assert response.status_code == 403
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+class TestAnimalProfileViewDeceased:
+ """AnimalProfileDetailView / AnimalTabView gate on deceased animals."""
+
+ @pytest.fixture
+ def deceased_animal(self, db, user_profile):
+ _, profile = user_profile
+ return Animal.objects.create(full_name="Passed", owner=profile, date_of_death=date(2024, 3, 15))
+
+ def _client_for(self, user):
+ from django.test import Client
+
+ c = Client()
+ c.force_login(user)
+ return c
+
+ def test_owner_can_view_deceased_profile(self, deceased_animal, user_profile):
+ user, _ = user_profile
+ response = self._client_for(user).get(f"/pet/{deceased_animal.id}/")
+ assert response.status_code == 200
+
+ def test_carer_blocked_on_deceased_profile(self, deceased_animal, second_user_profile, user_profile):
+ from ahc.apps.animals.models import AnimalShare
+
+ other_user, carer_profile = second_user_profile
+ AnimalShare.objects.create(animal=deceased_animal, carer=carer_profile)
+ response = self._client_for(other_user).get(f"/pet/{deceased_animal.id}/")
+ assert response.status_code == 403
+
+ def test_owner_blocked_from_settings_tab_on_deceased(self, deceased_animal, user_profile):
+ user, _ = user_profile
+ response = self._client_for(user).get(f"/pet/{deceased_animal.id}/tab/settings/")
+ assert response.status_code == 403
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+class TestStableAndArchiveViews:
+ """StableView excludes deceased; ArchiveView shows only owner's deceased."""
+
+ @pytest.fixture
+ def deceased_animal(self, db, user_profile):
+ _, profile = user_profile
+ return Animal.objects.create(full_name="Passed", owner=profile, date_of_death=date(2024, 3, 15))
+
+ def _client_for(self, user):
+ from django.test import Client
+
+ c = Client()
+ c.force_login(user)
+ return c
+
+ def test_stable_view_excludes_deceased(self, animal, deceased_animal, user_profile):
+ user, _ = user_profile
+ response = self._client_for(user).get("/pet/animals/")
+ assert response.status_code == 200
+ assert animal in response.context["animals"]
+ assert deceased_animal not in response.context["animals"]
+
+ def test_archive_view_includes_deceased(self, deceased_animal, user_profile):
+ user, _ = user_profile
+ response = self._client_for(user).get("/pet/archive/")
+ assert response.status_code == 200
+ assert deceased_animal in response.context["animals"]
+
+ def test_archive_view_excludes_living(self, animal, user_profile):
+ user, _ = user_profile
+ response = self._client_for(user).get("/pet/archive/")
+ assert animal not in response.context["animals"]
+
+ def test_archive_view_excludes_other_owners_deceased(self, deceased_animal, second_user_profile):
+ other_user, _ = second_user_profile
+ response = self._client_for(other_user).get("/pet/archive/")
+ assert deceased_animal not in response.context["animals"]
diff --git a/src/ahc/apps/animals/urls.py b/src/ahc/apps/animals/urls.py
index c9fb55b..9a8cbdc 100644
--- a/src/ahc/apps/animals/urls.py
+++ b/src/ahc/apps/animals/urls.py
@@ -22,6 +22,10 @@
path("
/details/", animal_owner_views.ChangeAnimalDetailsView.as_view(), name="animal_details"),
path("/keepers//remove/", animal_owner_views.RemoveKeeperView.as_view(), name="remove_keeper"),
path("/keepers//access/", animal_owner_views.EditShareView.as_view(), name="edit_share"),
+ path("/deceased/", animal_owner_views.MarkDeceasedView.as_view(), name="animal_deceased"),
+ path("/memorial/", animal_owner_views.EditMemorialNoteView.as_view(), name="animal_memorial"),
+ path("/unarchive/", animal_owner_views.UnarchiveAnimalView.as_view(), name="animal_unarchive"),
path("animals/", animal_views.StableView.as_view(), name="animals_stable"),
+ path("archive/", animal_views.ArchiveView.as_view(), name="animals_archive"),
path("pinned-animals/", animal_views.ToPinAnimalsView.as_view(), name="pinned_animals"),
]
diff --git a/src/ahc/apps/animals/utils_owner/forms.py b/src/ahc/apps/animals/utils_owner/forms.py
index 35fd5cf..818cb79 100644
--- a/src/ahc/apps/animals/utils_owner/forms.py
+++ b/src/ahc/apps/animals/utils_owner/forms.py
@@ -176,3 +176,30 @@ class ChangeAnimalDetailsForm(forms.ModelForm):
class Meta:
model = Animal
fields = ["species", "breed", "sex", "sterilization"]
+
+
+class MarkDeceasedForm(forms.ModelForm):
+ class Meta:
+ model = Animal
+ fields = ["date_of_death", "memorial_note"]
+ widgets = {
+ "date_of_death": forms.DateInput(attrs={"type": "date"}),
+ "memorial_note": forms.Textarea(attrs={"rows": 6, "cols": 2}),
+ }
+
+ def clean_date_of_death(self):
+ dod = self.cleaned_data.get("date_of_death")
+ if dod is None:
+ raise forms.ValidationError("Date of death is required to archive an animal.")
+ if dod > date.today():
+ raise forms.ValidationError("Date of death cannot be set in the future.")
+ return dod
+
+
+class EditMemorialNoteForm(forms.ModelForm):
+ class Meta:
+ model = Animal
+ fields = ["memorial_note"]
+ widgets = {
+ "memorial_note": forms.Textarea(attrs={"rows": 8, "cols": 2}),
+ }
diff --git a/src/ahc/apps/animals/utils_owner/views.py b/src/ahc/apps/animals/utils_owner/views.py
index 347a82e..735a905 100644
--- a/src/ahc/apps/animals/utils_owner/views.py
+++ b/src/ahc/apps/animals/utils_owner/views.py
@@ -16,10 +16,13 @@
remove_keeper,
set_animal_details,
set_birthday,
+ set_deceased,
set_dietary_restrictions,
set_first_contact,
+ set_memorial_note,
set_next_visit,
transfer_ownership,
+ unset_deceased,
update_share,
)
from ahc.apps.animals.utils_owner.forms import (
@@ -29,9 +32,11 @@
ChangeFirstContactForm,
ChangeNextVisitForm,
ChangeOwnerForm,
+ EditMemorialNoteForm,
EditShareForm,
ImageUploadForm,
ManageKeepersForm,
+ MarkDeceasedForm,
)
if TYPE_CHECKING:
@@ -289,3 +294,73 @@ def form_valid(self, form):
}
update_share(form.instance, scope=scope, valid_until=cd.get("valid_until"))
return redirect(reverse("animal_tab", kwargs={"pk": self.kwargs["pk"], "slug": "ownership"}))
+
+
+class MarkDeceasedView(LoginRequiredMixin, UserPassesOwnershipTestMixin, FormView):
+ """Record the animal as deceased and store an optional memorial note.
+
+ Uses UserPassesOwnershipTestMixin (which checks is_animal_owner directly) so this
+ view works even when the animal is already marked deceased, allowing owners to
+ correct the date or update the memorial note.
+ """
+
+ form_class = MarkDeceasedForm
+ template_name = "animals/change_deceased.html"
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["instance"] = get_object_or_404(Animal, pk=self.kwargs["pk"])
+ return kwargs
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["animal_id"] = self.kwargs["pk"]
+ return context
+
+ def form_valid(self, form):
+ set_deceased(
+ get_object_or_404(Animal, pk=self.kwargs["pk"]),
+ date_of_death=form.cleaned_data["date_of_death"],
+ memorial_note=form.cleaned_data["memorial_note"],
+ )
+ return redirect(reverse("animal_profile", kwargs={"pk": self.kwargs["pk"]}))
+
+
+class EditMemorialNoteView(LoginRequiredMixin, UserPassesOwnershipTestMixin, FormView):
+ """Edit the memorial note on a deceased animal (owner-only).
+
+ Intentionally bypasses user_can_modify_animal so the owner may update the
+ memorial note while the animal remains in the archived/deceased state.
+ """
+
+ form_class = EditMemorialNoteForm
+ template_name = "animals/change_memorial_note.html"
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["instance"] = get_object_or_404(Animal, pk=self.kwargs["pk"])
+ return kwargs
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["animal_id"] = self.kwargs["pk"]
+ return context
+
+ def form_valid(self, form):
+ set_memorial_note(
+ get_object_or_404(Animal, pk=self.kwargs["pk"]),
+ memorial_note=form.cleaned_data["memorial_note"],
+ )
+ return redirect(reverse("animal_profile", kwargs={"pk": self.kwargs["pk"]}))
+
+
+class UnarchiveAnimalView(LoginRequiredMixin, UserPassesOwnershipTestMixin, View):
+ """Reverse an archiving action — the animal becomes living again (owner-only, POST).
+
+ Uses UserPassesOwnershipTestMixin so this view works on a deceased animal.
+ Bypasses user_can_modify_animal by design: this is the intentional reversal path.
+ """
+
+ def post(self, request, pk, *args, **kwargs):
+ unset_deceased(get_object_or_404(Animal, pk=pk))
+ return redirect(reverse("animal_profile", kwargs={"pk": pk}))
diff --git a/src/ahc/apps/animals/views.py b/src/ahc/apps/animals/views.py
index 49b9867..93d4056 100644
--- a/src/ahc/apps/animals/views.py
+++ b/src/ahc/apps/animals/views.py
@@ -19,9 +19,10 @@
from ahc.apps.animals.selectors import (
allowed_categories_for,
animals_visible_to,
+ deceased_animals_for,
is_animal_owner,
is_pinned,
- user_can_access_animal,
+ user_can_view_animal,
)
from ahc.apps.animals.services import create_animal, pin_animal, unpin_animal
@@ -254,10 +255,13 @@ def _base_profile_context(request, animal: Animal) -> dict[str, Any]:
profile = request.user.profile
owner = is_animal_owner(profile, animal)
allowed = allowed_categories_for(profile, animal)
+ deceased = animal.is_deceased
def _tab_visible(tab: Tab) -> bool:
+ # Owner-only mutation tabs (settings, ownership) are hidden for deceased animals —
+ # the animal is read-only; the owner's only permitted actions are on the profile page.
if tab.owner_only:
- return owner
+ return owner and not deceased
if not tab.categories:
return True
return owner or bool(tab.categories & allowed)
@@ -265,6 +269,7 @@ def _tab_visible(tab: Tab) -> bool:
return {
"now": timezone.now().date(),
"is_owner": owner,
+ "is_deceased": deceased,
"is_pinned": is_pinned(profile, animal),
"allowed_categories": allowed,
"tabs": [t for t in TABS_LIST if _tab_visible(t)],
@@ -306,7 +311,7 @@ def get_context_data(self, **kwargs):
def test_func(self):
animal = self.get_object()
- return user_can_access_animal(self.request.user.profile, animal)
+ return user_can_view_animal(self.request.user.profile, animal)
class AnimalTabView(LoginRequiredMixin, UserPassesTestMixin, DetailView):
@@ -331,13 +336,15 @@ def _get_tab(self) -> Tab:
def test_func(self):
animal = self.get_object()
profile = self.request.user.profile
- if not user_can_access_animal(profile, animal):
+ if not user_can_view_animal(profile, animal):
return False
tab = TAB_REGISTRY.get(self.kwargs.get("slug", ""))
if tab is None:
return True
+ # Owner-only tabs (settings, ownership) are blocked on deceased animals to enforce
+ # the read-only archive mode. The owner's only write actions are on the profile page.
if tab.owner_only:
- return is_animal_owner(profile, animal)
+ return is_animal_owner(profile, animal) and not animal.is_deceased
if tab.categories and not is_animal_owner(profile, animal):
allowed = allowed_categories_for(profile, animal)
return bool(tab.categories & allowed)
@@ -372,6 +379,22 @@ def get_context_data(self, **kwargs):
return context
+class ArchiveView(LoginRequiredMixin, TemplateView):
+ """Owner-only read-only archive of deceased animals.
+
+ Only animals owned by the current user are shown — carers are intentionally
+ excluded because death withdraws management to the owner only.
+ """
+
+ template_name = "animals/archive.html"
+ request: AuthenticatedRequest
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["animals"] = deceased_animals_for(self.request.user.profile)
+ return context
+
+
class ToPinAnimalsView(LoginRequiredMixin, View):
request: AuthenticatedRequest
diff --git a/src/ahc/apps/homepage/migrations/0005_alter_profilebackground_content.py b/src/ahc/apps/homepage/migrations/0005_alter_profilebackground_content.py
new file mode 100644
index 0000000..9d69f22
--- /dev/null
+++ b/src/ahc/apps/homepage/migrations/0005_alter_profilebackground_content.py
@@ -0,0 +1,17 @@
+# Generated by Django 6.0.5 on 2026-06-04 19:46
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("homepage", "0004_delete_cronjob"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="profilebackground",
+ name="content",
+ field=models.ImageField(blank=True, default="", upload_to="static/media/background"),
+ ),
+ ]
diff --git a/src/ahc/apps/homepage/templates/homepage/homepage.html b/src/ahc/apps/homepage/templates/homepage/homepage.html
index e675408..1df47ca 100644
--- a/src/ahc/apps/homepage/templates/homepage/homepage.html
+++ b/src/ahc/apps/homepage/templates/homepage/homepage.html
@@ -16,6 +16,7 @@ Operations:
diff --git a/src/ahc/apps/homepage/views.py b/src/ahc/apps/homepage/views.py
index e100c62..de0c7f1 100644
--- a/src/ahc/apps/homepage/views.py
+++ b/src/ahc/apps/homepage/views.py
@@ -1,7 +1,6 @@
-from django.db.models import Q
from django.views.generic import TemplateView
-from ahc.apps.animals.models import Animal
+from ahc.apps.animals.selectors import animals_visible_to
from ahc.apps.users.models import Profile as UserProfile
@@ -17,17 +16,12 @@ def get_context_data(self, **kwargs):
return context
user_query = UserProfile.objects.get(user=user)
+ profile = user.profile
if user_query.allow_recennt_animals_list:
- pinned_animals_query = user_query.pinned_animals.all()
-
- context["pinned_animals"] = pinned_animals_query
-
- if user_query.allow_recennt_animals_list:
- recent_created_animals_query = Animal.objects.filter(
- Q(owner=self.request.user.profile) | Q(allowed_users=self.request.user.profile)
- ).order_by("-creation_date")[:3]
-
- context["recent_animals"] = recent_created_animals_query
+ # Deceased animals are excluded: pinned_animals may contain a now-deceased
+ # animal so we filter explicitly; animals_visible_to already excludes deceased.
+ context["pinned_animals"] = user_query.pinned_animals.filter(date_of_death__isnull=True)
+ context["recent_animals"] = animals_visible_to(profile).order_by("-creation_date")[:3]
return context
diff --git a/src/ahc/apps/medical_notes/forms/biometric_batch.py b/src/ahc/apps/medical_notes/forms/biometric_batch.py
new file mode 100644
index 0000000..87ee7d3
--- /dev/null
+++ b/src/ahc/apps/medical_notes/forms/biometric_batch.py
@@ -0,0 +1,74 @@
+"""Forms for the batch biometric measurement entry screen."""
+
+from __future__ import annotations
+
+from django import forms
+from django.forms import formset_factory
+
+from ahc.apps.medical_notes.forms.type_measurement_notes import BiometricRecordForm
+
+
+class BiometricBatchSessionForm(forms.Form):
+ """Controls the measurement type and unit for an entire batch session.
+
+ A single session covers one record_type (weight / height / custom) applied
+ to all animals on the screen. The optional `unit` field overrides the model
+ default for weight ("g") and height ("mm"). Custom measurements require both
+ `custom_name` and `custom_unit`.
+ """
+
+ record_type = forms.ChoiceField(choices=BiometricRecordForm.RECORD_CHOICES, label="Measurement type")
+ unit = forms.CharField(
+ max_length=12,
+ required=False,
+ label="Unit",
+ help_text="Leave blank to use the model default (g for weight, mm for height).",
+ )
+ custom_name = forms.CharField(
+ max_length=30,
+ required=False,
+ label="Measurement name",
+ help_text="Required when record type is Custom.",
+ )
+ custom_unit = forms.CharField(
+ max_length=12,
+ required=False,
+ label="Custom unit",
+ help_text="Required when record type is Custom.",
+ )
+
+ def clean(self):
+ cleaned = super().clean()
+ if cleaned.get("record_type") == "custom":
+ if not cleaned.get("custom_name"):
+ self.add_error("custom_name", "Measurement name is required for custom records.")
+ if not cleaned.get("custom_unit"):
+ self.add_error("custom_unit", "Custom unit is required for custom records.")
+ return cleaned
+
+
+class BiometricBatchRowForm(forms.Form):
+ """A single animal row in the batch measurement table.
+
+ The `animal_id` hidden field carries the animal UUID from GET to POST so the
+ view can look it up in the allowed set without trusting URL order. When
+ `include` is True, `value` must be provided.
+ """
+
+ include = forms.BooleanField(required=False, label="Include")
+ animal_id = forms.UUIDField(widget=forms.HiddenInput)
+ value = forms.DecimalField(
+ max_digits=8,
+ decimal_places=3,
+ required=False,
+ label="Value",
+ )
+
+ def clean(self):
+ cleaned = super().clean()
+ if cleaned.get("include") and cleaned.get("value") is None:
+ self.add_error("value", "A value is required for checked animals.")
+ return cleaned
+
+
+BiometricBatchFormSet = formset_factory(BiometricBatchRowForm, extra=0)
diff --git a/src/ahc/apps/medical_notes/forms/type_basic_note.py b/src/ahc/apps/medical_notes/forms/type_basic_note.py
index 867c092..b4680a2 100644
--- a/src/ahc/apps/medical_notes/forms/type_basic_note.py
+++ b/src/ahc/apps/medical_notes/forms/type_basic_note.py
@@ -1,5 +1,6 @@
from django import forms
+from ahc.apps.animals.selectors import animals_visible_to
from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord, MedicalRecordAttachment
@@ -50,13 +51,22 @@ class Meta:
def __init__(self, *args, **kwargs):
animal_choices = kwargs.pop("animal_choices", None)
+ profile = kwargs.pop("profile", None)
+ exclude_id = kwargs.pop("exclude_id", None)
type_of_event_param = kwargs.pop("type_of_event_param", None)
super().__init__(*args, **kwargs)
- # self.Meta.fields.append('TYPES_OF_EVENTS')
if animal_choices:
self.fields["additional_animals"].widget.choices = animal_choices
+ # Restrict the validation queryset so a posted UUID of a deceased or inaccessible
+ # animal fails form validation, not just widget display.
+ if profile is not None:
+ qs = animals_visible_to(profile)
+ if exclude_id is not None:
+ qs = qs.exclude(id=exclude_id)
+ self.fields["additional_animals"].queryset = qs
+
if type_of_event_param in set(event[0] for event in self.TYPES_OF_EVENTS):
self.fields["type_of_event"].initial = type_of_event_param
else:
@@ -105,12 +115,20 @@ def __init__(self, *args, **kwargs):
kwargs.pop("animal")
animal_choices = kwargs.pop("animal_choices", None)
is_author = kwargs.pop("is_author", None)
+ profile = kwargs.pop("profile", None)
super().__init__(*args, **kwargs)
if animal_choices:
self.fields["animal"].widget.choices = animal_choices
self.fields["additional_animals"].widget.choices = animal_choices
+ # Restrict validation querysets so a posted UUID of a deceased or inaccessible
+ # animal fails form validation, not just widget display.
+ if profile is not None:
+ qs = animals_visible_to(profile)
+ self.fields["animal"].queryset = qs
+ self.fields["additional_animals"].queryset = qs
+
if not is_author:
del self.fields["animal"]
diff --git a/src/ahc/apps/medical_notes/selectors.py b/src/ahc/apps/medical_notes/selectors.py
index ced9c77..9dd3efb 100644
--- a/src/ahc/apps/medical_notes/selectors.py
+++ b/src/ahc/apps/medical_notes/selectors.py
@@ -13,7 +13,7 @@
from django.db.models import QuerySet
from django.utils import timezone
-from ahc.apps.animals.selectors import animals_visible_to, user_can_access_animal
+from ahc.apps.animals.selectors import animals_visible_to, user_can_modify_animal
from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord, MedicalRecordAttachment
@@ -194,8 +194,12 @@ def is_attachment_author(profile, attachment: MedicalRecordAttachment) -> bool:
def can_access_note_animal(profile, note: MedicalRecord) -> bool:
- """Return True if the profile is owner or keeper of the animal linked to the note."""
- return user_can_access_animal(profile, note.animal)
+ """Return True if the profile may write to the animal linked to the note.
+
+ Uses user_can_modify_animal so that deceased animals are always blocked — neither
+ the owner nor any carer may add or edit notes on a deceased animal.
+ """
+ return user_can_modify_animal(profile, note.animal)
def vaccination_notes_for(animal) -> QuerySet:
@@ -226,6 +230,7 @@ def due_vaccination_reminders(on_date: date) -> QuerySet:
return (
VaccinationNote.objects.filter(reminder_date__lte=on_date, reminder_sent=False)
+ .filter(related_note__animal__date_of_death__isnull=True)
.select_related("related_note__animal__owner__user")
.exclude(reminder_date=None)
)
diff --git a/src/ahc/apps/medical_notes/services/biometrics.py b/src/ahc/apps/medical_notes/services/biometrics.py
index 0b03652..80d90a0 100644
--- a/src/ahc/apps/medical_notes/services/biometrics.py
+++ b/src/ahc/apps/medical_notes/services/biometrics.py
@@ -2,6 +2,9 @@
from __future__ import annotations
+from django.db import transaction
+
+from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord
from ahc.apps.medical_notes.models.type_measurement_notes import (
BiometricCustomRecords,
BiometricHeightRecords,
@@ -48,3 +51,45 @@ def create_biometric_record(animal, related_note, record_type: str, data: dict)
related_note=related_note,
custom_biometric_record=sub_record,
)
+
+
+def create_batch_biometric_records(
+ author_profile,
+ record_type: str,
+ rows: list[tuple],
+ allowed_ids: set,
+) -> int:
+ """Create a MedicalRecord + BiometricRecord pair for each (animal, data_dict) row.
+
+ Rows whose animal.id is absent from allowed_ids are silently skipped — this is a
+ defence-in-depth fence; the caller (view) should already have filtered rows to the
+ permitted set.
+
+ Pairs are created one after the other inside a single transaction. This ordering is
+ intentional: the clean_orphaned_metric_records post_save signal on BiometricRecord
+ deletes any biometric_record MedicalRecords by the same author that have no attached
+ BiometricRecord at the moment of each save. Creating all notes before all biometries
+ would cause the first biometry save to delete the still-empty sibling notes. Creating
+ each (note, biometry) pair in sequence avoids that.
+
+ Returns the number of pairs created.
+ """
+ count = 0
+ with transaction.atomic():
+ for animal, data_dict in rows:
+ if animal.id not in allowed_ids:
+ continue
+ note = MedicalRecord.objects.create(
+ animal=animal,
+ author=author_profile,
+ type_of_event="biometric_record",
+ short_description=f"Measurement: {record_type}",
+ )
+ create_biometric_record(
+ animal=animal,
+ related_note=note,
+ record_type=record_type,
+ data=data_dict,
+ )
+ count += 1
+ return count
diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/biometric_batch.html b/src/ahc/apps/medical_notes/templates/medical_notes/biometric_batch.html
new file mode 100644
index 0000000..eaaa799
--- /dev/null
+++ b/src/ahc/apps/medical_notes/templates/medical_notes/biometric_batch.html
@@ -0,0 +1,63 @@
+{% extends "homepage/base.html" %}
+{% load static %}
+{% block extra_js %}
+
+{% endblock %}
+{% block content %}
+
+
Batch measurement entry
+
Select a measurement type, check each animal you measured, enter the value, then save all at once.
+
+
+
+
+
+ Back to homepage
+
+
+{% endblock %}
diff --git a/src/ahc/apps/medical_notes/tests.py b/src/ahc/apps/medical_notes/tests.py
index f510733..91a8ae1 100644
--- a/src/ahc/apps/medical_notes/tests.py
+++ b/src/ahc/apps/medical_notes/tests.py
@@ -198,10 +198,10 @@ def test_is_attachment_author_false_for_other_profile(self):
attachment.medical_record.author = MagicMock()
assert is_attachment_author(MagicMock(), attachment) is False
- def test_can_access_note_animal_delegates_to_animals_selector(self):
+ def test_can_access_note_animal_delegates_to_modify_predicate(self):
profile = MagicMock()
note = MagicMock()
- with patch("ahc.apps.medical_notes.selectors.user_can_access_animal", return_value=True) as mock_selector:
+ with patch("ahc.apps.medical_notes.selectors.user_can_modify_animal", return_value=True) as mock_selector:
result = can_access_note_animal(profile, note)
mock_selector.assert_called_once_with(profile, note.animal)
@@ -210,7 +210,7 @@ def test_can_access_note_animal_delegates_to_animals_selector(self):
def test_can_access_note_animal_returns_false_when_denied(self):
profile = MagicMock()
note = MagicMock()
- with patch("ahc.apps.medical_notes.selectors.user_can_access_animal", return_value=False):
+ with patch("ahc.apps.medical_notes.selectors.user_can_modify_animal", return_value=False):
assert can_access_note_animal(profile, note) is False
@@ -822,3 +822,359 @@ def test_stranger_is_denied(self, client, second_user_profile, diet_note_shell):
url = reverse("note_related_diets", kwargs={"pk": diet_note_shell.id})
response = client.get(url)
assert response.status_code == 403
+
+
+# ---------------------------------------------------------------------------
+# Batch biometric entry — forms
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.unit
+class TestBiometricBatchRowForm:
+ """BiometricBatchRowForm: include+value cross-field validation."""
+
+ def test_checked_row_without_value_is_invalid(self):
+ import uuid
+
+ from ahc.apps.medical_notes.forms.biometric_batch import BiometricBatchRowForm
+
+ form = BiometricBatchRowForm(data={"include": True, "animal_id": str(uuid.uuid4()), "value": ""})
+ assert not form.is_valid()
+ assert "value" in form.errors
+
+ def test_unchecked_row_without_value_is_valid(self):
+ import uuid
+
+ from ahc.apps.medical_notes.forms.biometric_batch import BiometricBatchRowForm
+
+ form = BiometricBatchRowForm(data={"include": False, "animal_id": str(uuid.uuid4()), "value": ""})
+ assert form.is_valid()
+
+ def test_checked_row_with_value_is_valid(self):
+ import uuid
+
+ from ahc.apps.medical_notes.forms.biometric_batch import BiometricBatchRowForm
+
+ form = BiometricBatchRowForm(data={"include": True, "animal_id": str(uuid.uuid4()), "value": "12.500"})
+ assert form.is_valid()
+
+
+@pytest.mark.unit
+class TestBiometricBatchSessionForm:
+ """BiometricBatchSessionForm: custom type requires name and unit."""
+
+ def test_custom_type_without_name_and_unit_is_invalid(self):
+ from ahc.apps.medical_notes.forms.biometric_batch import BiometricBatchSessionForm
+
+ form = BiometricBatchSessionForm(data={"record_type": "custom", "unit": "", "custom_name": "", "custom_unit": ""})
+ assert not form.is_valid()
+ assert "custom_name" in form.errors
+ assert "custom_unit" in form.errors
+
+ def test_weight_type_without_unit_is_valid(self):
+ from ahc.apps.medical_notes.forms.biometric_batch import BiometricBatchSessionForm
+
+ form = BiometricBatchSessionForm(data={"record_type": "weight", "unit": "", "custom_name": "", "custom_unit": ""})
+ assert form.is_valid()
+
+
+# ---------------------------------------------------------------------------
+# Batch biometric entry — service
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def batch_animals(db, user_profile):
+ """Three animals owned by user_profile for batch service tests."""
+ from ahc.apps.animals.models import Animal
+
+ _, profile = user_profile
+ a1 = Animal.objects.create(full_name="Alpha", owner=profile)
+ a2 = Animal.objects.create(full_name="Beta", owner=profile)
+ a3 = Animal.objects.create(full_name="Gamma", owner=profile)
+ return (a1, a2, a3), profile
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+class TestCreateBatchBiometricRecordsService:
+ """create_batch_biometric_records: creates N pairs, enforces allowed_ids, no signal orphans."""
+
+ def test_creates_expected_number_of_pairs(self, batch_animals):
+ from decimal import Decimal
+
+ from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord
+ from ahc.apps.medical_notes.models.type_measurement_notes import BiometricRecord
+ from ahc.apps.medical_notes.services.biometrics import create_batch_biometric_records
+
+ (a1, a2, _), profile = batch_animals
+ rows = [
+ (a1, {"weight": Decimal("4.5"), "weight_unit_to_present": "kg"}),
+ (a2, {"weight": Decimal("8.0"), "weight_unit_to_present": "kg"}),
+ ]
+ n = create_batch_biometric_records(profile, "weight", rows, allowed_ids={a1.id, a2.id})
+
+ assert n == 2
+ assert MedicalRecord.objects.filter(type_of_event="biometric_record", author=profile).count() == 2
+ assert BiometricRecord.objects.filter(animal__in=[a1, a2]).count() == 2
+
+ def test_skips_animal_not_in_allowed_ids(self, batch_animals):
+ import uuid
+ from decimal import Decimal
+
+ from ahc.apps.medical_notes.models.type_measurement_notes import BiometricRecord
+ from ahc.apps.medical_notes.services.biometrics import create_batch_biometric_records
+
+ (a1, _, _), profile = batch_animals
+ outsider_id = uuid.uuid4()
+ rows = [
+ (a1, {"weight": Decimal("5.0"), "weight_unit_to_present": "kg"}),
+ ]
+ n = create_batch_biometric_records(profile, "weight", rows, allowed_ids={outsider_id})
+
+ assert n == 0
+ assert BiometricRecord.objects.count() == 0
+
+ def test_no_orphaned_notes_after_batch(self, batch_animals):
+ """Regression: the clean_orphaned_metric_records signal must not delete sibling notes.
+
+ This test fails if the service creates all MedicalRecord rows before any
+ BiometricRecord — the first BiometricRecord save would then wipe the still-empty
+ sibling notes. Correct sequential (note, biometry) pairing prevents this.
+ """
+ from decimal import Decimal
+
+ from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord
+ from ahc.apps.medical_notes.models.type_measurement_notes import BiometricRecord
+ from ahc.apps.medical_notes.services.biometrics import create_batch_biometric_records
+
+ (a1, a2, a3), profile = batch_animals
+ rows = [
+ (a1, {"weight": Decimal("3.0"), "weight_unit_to_present": "g"}),
+ (a2, {"weight": Decimal("6.0"), "weight_unit_to_present": "g"}),
+ (a3, {"weight": Decimal("9.0"), "weight_unit_to_present": "g"}),
+ ]
+ create_batch_biometric_records(profile, "weight", rows, allowed_ids={a1.id, a2.id, a3.id})
+
+ note_count = MedicalRecord.objects.filter(type_of_event="biometric_record", author=profile).count()
+ biometric_count = BiometricRecord.objects.filter(animal__in=[a1, a2, a3]).count()
+ assert note_count == 3, f"Expected 3 notes, got {note_count} (signal deleted orphans)"
+ assert biometric_count == 3
+
+
+# ---------------------------------------------------------------------------
+# Batch biometric entry — view integration
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def two_owned_animals(db, user_profile):
+ """Two animals owned by user_profile for view tests."""
+ from ahc.apps.animals.models import Animal
+
+ _, profile = user_profile
+ a1 = Animal.objects.create(full_name="Dog", owner=profile)
+ a2 = Animal.objects.create(full_name="Cat", owner=profile)
+ return (a1, a2), profile
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+class TestBiometricBatchCreateView:
+ """BiometricBatchCreateView: GET renders formset rows, POST creates pairs, stranger blocked."""
+
+ def test_get_renders_one_row_per_animal(self, client, user_profile, two_owned_animals):
+ user, _ = user_profile
+ client.force_login(user)
+ response = client.get(reverse("biometric_batch"))
+
+ assert response.status_code == 200
+ content = response.content.decode()
+ assert "Dog" in content
+ assert "Cat" in content
+
+ def test_post_creates_pairs_for_checked_rows_only(self, client, user_profile, two_owned_animals):
+ from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord
+ from ahc.apps.medical_notes.models.type_measurement_notes import BiometricRecord
+
+ user, _ = user_profile
+ (a1, a2), _ = two_owned_animals
+ client.force_login(user)
+
+ data = {
+ "record_type": "weight",
+ "unit": "kg",
+ "custom_name": "",
+ "custom_unit": "",
+ "form-TOTAL_FORMS": "2",
+ "form-INITIAL_FORMS": "0",
+ "form-MIN_NUM_FORMS": "0",
+ "form-MAX_NUM_FORMS": "1000",
+ "form-0-include": "on",
+ "form-0-animal_id": str(a1.id),
+ "form-0-value": "12.5",
+ "form-1-include": "",
+ "form-1-animal_id": str(a2.id),
+ "form-1-value": "",
+ }
+ response = client.post(reverse("biometric_batch"), data)
+
+ assert response.status_code == 302
+ assert MedicalRecord.objects.filter(type_of_event="biometric_record").count() == 1
+ assert BiometricRecord.objects.filter(animal=a1).count() == 1
+ assert BiometricRecord.objects.filter(animal=a2).count() == 0
+
+ def test_post_ignores_animal_outside_allowed_set(self, client, user_profile, second_user_profile, two_owned_animals):
+ """A row carrying a stranger's animal_id must produce no records."""
+ from ahc.apps.medical_notes.models.type_measurement_notes import BiometricRecord
+
+ user, _ = user_profile
+ _, other_profile = second_user_profile
+ from ahc.apps.animals.models import Animal
+
+ stranger_animal = Animal.objects.create(full_name="Stranger", owner=other_profile)
+ client.force_login(user)
+
+ data = {
+ "record_type": "weight",
+ "unit": "kg",
+ "custom_name": "",
+ "custom_unit": "",
+ "form-TOTAL_FORMS": "1",
+ "form-INITIAL_FORMS": "0",
+ "form-MIN_NUM_FORMS": "0",
+ "form-MAX_NUM_FORMS": "1000",
+ "form-0-include": "on",
+ "form-0-animal_id": str(stranger_animal.id),
+ "form-0-value": "5.0",
+ }
+ response = client.post(reverse("biometric_batch"), data)
+
+ assert response.status_code == 302
+ assert BiometricRecord.objects.count() == 0
+
+ def test_unauthenticated_redirects_to_login(self, client):
+ response = client.get(reverse("biometric_batch"))
+ assert response.status_code == 302
+ assert "/login" in response["Location"]
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+class TestDeceasedAnimalWriteBlocking:
+ """Verify all medical-notes write paths block deceased animals."""
+
+ @pytest.fixture
+ def setup(self, db, user_profile, second_user_profile):
+ from ahc.apps.animals.models import Animal, AnimalShare
+ from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord
+
+ _, owner_profile = user_profile
+ owner_user, _ = user_profile
+ _, carer_profile = second_user_profile
+ carer_user, _ = second_user_profile
+
+ living = Animal.objects.create(full_name="Living", owner=owner_profile)
+ deceased = Animal.objects.create(full_name="Passed", owner=owner_profile, date_of_death=_date(2024, 1, 1))
+ AnimalShare.objects.create(animal=deceased, carer=carer_profile)
+ AnimalShare.objects.create(animal=living, carer=carer_profile)
+
+ note_on_living = MedicalRecord.objects.create(
+ animal=living, author=owner_profile, short_description="live note", type_of_event="fast_note"
+ )
+ return {
+ "owner_user": owner_user,
+ "carer_user": carer_user,
+ "owner_profile": owner_profile,
+ "carer_profile": carer_profile,
+ "living": living,
+ "deceased": deceased,
+ "note_on_living": note_on_living,
+ }
+
+ def _client_for(self, user):
+ from django.test import Client
+
+ c = Client()
+ c.force_login(user)
+ return c
+
+ def test_owner_cannot_create_note_on_deceased(self, setup):
+ s = setup
+ # medical_notes URLs are mounted under /note/ (see ahc/urls.py)
+ response = self._client_for(s["owner_user"]).post(
+ f"/note/{s['deceased'].id}/create/",
+ {"type_of_event": "fast_note", "short_description": "new note"},
+ )
+ assert response.status_code == 403
+
+ def test_carer_cannot_create_note_on_deceased(self, setup):
+ s = setup
+ response = self._client_for(s["carer_user"]).post(
+ f"/note/{s['deceased'].id}/create/",
+ {"type_of_event": "fast_note", "short_description": "carer note"},
+ )
+ assert response.status_code == 403
+
+ def test_deceased_animal_not_in_batch_allowed_set(self, setup):
+ """Deceased animal must never appear in the formset offered by BiometricBatchCreateView."""
+ s = setup
+ response = self._client_for(s["owner_user"]).get(reverse("biometric_batch"))
+ assert response.status_code == 200
+ # BiometricBatchCreateView._build_context stores animals inside 'rows' as (form, animal) tuples
+ offered_ids = {str(animal.id) for _, animal in response.context["rows"]}
+ assert str(s["deceased"].id) not in offered_ids
+ assert str(s["living"].id) in offered_ids
+
+ def test_form_queryset_rejects_deceased_uuid_in_additional_animals(self, setup):
+ """MedicalRecordForm.additional_animals queryset must reject a deceased animal UUID."""
+ from ahc.apps.medical_notes.forms.type_basic_note import MedicalRecordForm
+
+ s = setup
+ form = MedicalRecordForm(
+ data={
+ "type_of_event": "fast_note",
+ "short_description": "test",
+ "additional_animals": [str(s["deceased"].id)],
+ },
+ profile=s["owner_profile"],
+ exclude_id=s["living"].id,
+ )
+ assert not form.is_valid()
+ assert "additional_animals" in form.errors
+
+ def test_can_access_note_animal_returns_false_for_deceased(self, setup):
+ """can_access_note_animal must block even the owner on a deceased animal's note."""
+ from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord
+ from ahc.apps.medical_notes.selectors import can_access_note_animal
+
+ s = setup
+ deceased_note = MedicalRecord.objects.create(
+ animal=s["deceased"], author=s["owner_profile"], short_description="old note", type_of_event="fast_note"
+ )
+ assert can_access_note_animal(s["owner_profile"], deceased_note) is False
+ assert can_access_note_animal(s["carer_profile"], deceased_note) is False
+
+ def test_due_vaccination_reminders_excludes_deceased(self, setup):
+ """Vaccination reminders must not fire for deceased animals."""
+ from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord
+ from ahc.apps.medical_notes.models.type_vaccination_notes import VaccinationNote
+ from ahc.apps.medical_notes.selectors import due_vaccination_reminders
+
+ s = setup
+ living_note = MedicalRecord.objects.create(
+ animal=s["living"], author=s["owner_profile"], short_description="vacc base", type_of_event="vaccination_note"
+ )
+ deceased_note = MedicalRecord.objects.create(
+ animal=s["deceased"],
+ author=s["owner_profile"],
+ short_description="deceased vacc",
+ type_of_event="vaccination_note",
+ )
+ today = _date(2025, 1, 1)
+ living_vacc = VaccinationNote.objects.create(related_note=living_note, reminder_date=today, reminder_sent=False)
+ deceased_vacc = VaccinationNote.objects.create(related_note=deceased_note, reminder_date=today, reminder_sent=False)
+ reminders = list(due_vaccination_reminders(today))
+ reminder_ids = {v.pk for v in reminders}
+ assert living_vacc.pk in reminder_ids
+ assert deceased_vacc.pk not in reminder_ids
diff --git a/src/ahc/apps/medical_notes/urls.py b/src/ahc/apps/medical_notes/urls.py
index 25b8744..08e39f4 100644
--- a/src/ahc/apps/medical_notes/urls.py
+++ b/src/ahc/apps/medical_notes/urls.py
@@ -23,6 +23,7 @@
measurement_views.BiometricRecordCreateView.as_view(),
name="biometric_create",
),
+ path("biometric/batch/", measurement_views.BiometricBatchCreateView.as_view(), name="biometric_batch"),
path("
/attachment_edit/", notes_views.EditMedicalRecordAttachmentDescription.as_view(), name="attachment_edit"),
path("/attachment_delete/", notes_views.DeleteMedicalRecordAttachment.as_view(), name="attachment_delete"),
path(
diff --git a/src/ahc/apps/medical_notes/views/mixins/user_animal_permisions.py b/src/ahc/apps/medical_notes/views/mixins/user_animal_permisions.py
index 2e57e8e..1f5716c 100644
--- a/src/ahc/apps/medical_notes/views/mixins/user_animal_permisions.py
+++ b/src/ahc/apps/medical_notes/views/mixins/user_animal_permisions.py
@@ -3,12 +3,18 @@
Each mixin implements test_func by delegating to a selector from
ahc.apps.medical_notes.selectors, keeping views free of inline permission logic.
-Two access-level patterns exist:
-- AnimalDirectAccessRequiredMixin — pk in URL is an Animal UUID directly.
-- AnimalAccessRequiredMixin — pk in URL is a MedicalRecord UUID; access is
- checked on the linked animal.
+Four access-level patterns exist:
+- AnimalDirectViewMixin — pk in URL is an Animal UUID; grants read-only access
+ (owner always; carer only if living animal + active share).
+- AnimalDirectModifyMixin — pk in URL is an Animal UUID; grants write access
+ (blocked entirely on deceased animals, even for the owner).
+- AnimalAccessRequiredMixin — pk in URL is a MedicalRecord UUID; write access
+ checked on the linked animal via can_access_note_animal.
- NoteAuthorRequiredMixin — pk is a MedicalRecord UUID; author-only access.
- AttachmentAuthorRequiredMixin — pk is a MedicalRecordAttachment UUID.
+
+AnimalDirectAccessRequiredMixin is kept as a backward-compatible alias for
+AnimalDirectViewMixin; prefer the explicit names in new code.
"""
from __future__ import annotations
@@ -19,7 +25,7 @@
from django.shortcuts import get_object_or_404
from ahc.apps.animals.models import Animal
-from ahc.apps.animals.selectors import user_can_access_animal
+from ahc.apps.animals.selectors import user_can_modify_animal, user_can_view_animal
from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord, MedicalRecordAttachment
from ahc.apps.medical_notes.selectors import (
can_access_note_animal,
@@ -31,18 +37,39 @@
from ahc.types import AuthenticatedRequest
-class AnimalDirectAccessRequiredMixin(UserPassesTestMixin):
- """Allow access when pk in URL is an Animal UUID and the profile can access it."""
+class AnimalDirectViewMixin(UserPassesTestMixin):
+ """Allow read access when pk in URL is an Animal UUID.
+
+ The owner may always view (including deceased animals in read-only mode).
+ Carers are blocked on deceased animals.
+ """
+
+ request: AuthenticatedRequest
+
+ def test_func(self):
+ animal = get_object_or_404(Animal, id=self.kwargs.get("pk"))
+ return user_can_view_animal(self.request.user.profile, animal)
+
+
+class AnimalDirectModifyMixin(UserPassesTestMixin):
+ """Allow write access when pk in URL is an Animal UUID.
+
+ Deceased animals are blocked for everyone — including the owner.
+ """
request: AuthenticatedRequest
def test_func(self):
animal = get_object_or_404(Animal, id=self.kwargs.get("pk"))
- return user_can_access_animal(self.request.user.profile, animal)
+ return user_can_modify_animal(self.request.user.profile, animal)
+
+
+# Backward-compatible alias — existing views that only need read access continue to work.
+AnimalDirectAccessRequiredMixin = AnimalDirectViewMixin
class AnimalAccessRequiredMixin(UserPassesTestMixin):
- """Allow access when pk in URL is a MedicalRecord UUID and the profile can access its animal."""
+ """Allow write access when pk in URL is a MedicalRecord UUID and the profile may write to its animal."""
request: AuthenticatedRequest
diff --git a/src/ahc/apps/medical_notes/views/type_basic_note.py b/src/ahc/apps/medical_notes/views/type_basic_note.py
index 816fd9f..dfa6f15 100644
--- a/src/ahc/apps/medical_notes/views/type_basic_note.py
+++ b/src/ahc/apps/medical_notes/views/type_basic_note.py
@@ -39,6 +39,7 @@
from ahc.apps.medical_notes.utils import build_timeline_base_query
from ahc.apps.medical_notes.views.mixins.user_animal_permisions import (
AnimalDirectAccessRequiredMixin,
+ AnimalDirectModifyMixin,
AttachmentAuthorRequiredMixin,
NoteAuthorRequiredMixin,
)
@@ -47,7 +48,7 @@
from ahc.types import AuthenticatedRequest
-class CreateNoteFormView(LoginRequiredMixin, AnimalDirectAccessRequiredMixin, FormView):
+class CreateNoteFormView(LoginRequiredMixin, AnimalDirectModifyMixin, FormView):
template_name = "medical_notes/create.html"
form_class = MedicalRecordForm
request: AuthenticatedRequest
@@ -59,7 +60,11 @@ def get_template_names(self):
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
- kwargs["animal_choices"] = animal_choices_for(self.request.user.profile, exclude_id=self.kwargs.get("pk"))
+ profile = self.request.user.profile
+ primary_id = self.kwargs.get("pk")
+ kwargs["animal_choices"] = animal_choices_for(profile, exclude_id=primary_id)
+ kwargs["profile"] = profile
+ kwargs["exclude_id"] = primary_id
kwargs["type_of_event_param"] = self.request.GET.get("type_of_event")
return kwargs
@@ -202,7 +207,9 @@ def get_context_data(self, **kwargs):
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
- kwargs["animal_choices"] = animal_choices_for(self.request.user.profile)
+ profile = self.request.user.profile
+ kwargs["animal_choices"] = animal_choices_for(profile)
+ kwargs["profile"] = profile
note = get_object_or_404(MedicalRecord, pk=self.kwargs.get("pk"))
kwargs["animal"] = get_object_or_404(AnimalProfile, id=note.animal.id)
diff --git a/src/ahc/apps/medical_notes/views/type_measurement_notes.py b/src/ahc/apps/medical_notes/views/type_measurement_notes.py
index beb2f22..ea664e1 100644
--- a/src/ahc/apps/medical_notes/views/type_measurement_notes.py
+++ b/src/ahc/apps/medical_notes/views/type_measurement_notes.py
@@ -1,16 +1,27 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
-from django.shortcuts import get_object_or_404, redirect
+from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
+from django.views import View
from django.views.generic.edit import FormView
from ahc.apps.animals.models import Animal as AnimalProfile
+from ahc.apps.animals.selectors import animals_for_biometric_batch
+from ahc.apps.medical_notes.forms.biometric_batch import BiometricBatchFormSet, BiometricBatchSessionForm
from ahc.apps.medical_notes.forms.type_measurement_notes import BiometricRecordForm
from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord
-from ahc.apps.medical_notes.services.biometrics import create_biometric_record
-from ahc.apps.medical_notes.views.mixins.user_animal_permisions import AnimalDirectAccessRequiredMixin
+from ahc.apps.medical_notes.services.biometrics import create_batch_biometric_records, create_biometric_record
+from ahc.apps.medical_notes.views.mixins.user_animal_permisions import AnimalDirectModifyMixin
+if TYPE_CHECKING:
+ from ahc.types import AuthenticatedRequest
-class BiometricRecordCreateView(LoginRequiredMixin, AnimalDirectAccessRequiredMixin, FormView):
+
+class BiometricRecordCreateView(LoginRequiredMixin, AnimalDirectModifyMixin, FormView):
template_name = "medical_notes/create.html"
form_class = BiometricRecordForm
@@ -34,3 +45,68 @@ def form_valid(self, form):
)
return redirect(reverse("animal_profile", kwargs={"pk": animal_id}))
+
+
+class BiometricBatchCreateView(LoginRequiredMixin, View):
+ """Full-page form for entering one measurement type for multiple animals at once.
+
+ A single POST creates one MedicalRecord + BiometricRecord pair per checked row.
+ The session form controls the record type and unit; the formset has one row per
+ animal accessible to the current user.
+ """
+
+ template_name = "medical_notes/biometric_batch.html"
+ request: AuthenticatedRequest
+
+ def _build_context(self, session_form, formset, animals):
+ return {
+ "session_form": session_form,
+ "formset": formset,
+ "rows": list(zip(formset.forms, animals, strict=False)),
+ }
+
+ def get(self, request, *args, **kwargs):
+ animals = list(animals_for_biometric_batch(request.user.profile))
+ session_form = BiometricBatchSessionForm()
+ formset = BiometricBatchFormSet(initial=[{"animal_id": a.id} for a in animals])
+ return render(request, self.template_name, self._build_context(session_form, formset, animals))
+
+ def post(self, request, *args, **kwargs):
+ animals = list(animals_for_biometric_batch(request.user.profile))
+ session_form = BiometricBatchSessionForm(request.POST)
+ formset = BiometricBatchFormSet(request.POST)
+
+ if not (session_form.is_valid() and formset.is_valid()):
+ return render(request, self.template_name, self._build_context(session_form, formset, animals))
+
+ profile = request.user.profile
+ allowed = {a.id: a for a in animals}
+ record_type = session_form.cleaned_data["record_type"]
+ unit = session_form.cleaned_data.get("unit") or ""
+ custom_name = session_form.cleaned_data.get("custom_name", "")
+ custom_unit = session_form.cleaned_data.get("custom_unit", "")
+
+ rows = []
+ for form in formset.forms:
+ if not form.cleaned_data.get("include"):
+ continue
+ animal_id = form.cleaned_data["animal_id"]
+ if animal_id not in allowed:
+ continue
+ value = form.cleaned_data["value"]
+ if record_type == "weight":
+ data_dict = {"weight": value, "weight_unit_to_present": unit or "g"}
+ elif record_type == "height":
+ data_dict = {"height": value, "height_unit_to_present": unit or "mm"}
+ else:
+ data_dict = {"custom_name": custom_name, "custom_value": str(value), "custom_unit": custom_unit}
+ rows.append((allowed[animal_id], data_dict))
+
+ n = create_batch_biometric_records(
+ author_profile=profile,
+ record_type=record_type,
+ rows=rows,
+ allowed_ids=set(allowed.keys()),
+ )
+ messages.success(request, f"Saved {n} measurement(s).")
+ return redirect(reverse("biometric_batch"))
diff --git a/src/ahc/apps/medical_notes/views/type_vaccination_notes.py b/src/ahc/apps/medical_notes/views/type_vaccination_notes.py
index fd249e6..c0b93a6 100644
--- a/src/ahc/apps/medical_notes/views/type_vaccination_notes.py
+++ b/src/ahc/apps/medical_notes/views/type_vaccination_notes.py
@@ -16,7 +16,7 @@
from django.views import View
from ahc.apps.animals.models import Animal
-from ahc.apps.animals.selectors import allowed_categories_for, is_animal_owner, user_can_access_animal
+from ahc.apps.animals.selectors import allowed_categories_for, is_animal_owner, user_can_modify_animal
if TYPE_CHECKING:
from ahc.types import AuthenticatedRequest
@@ -30,8 +30,12 @@
def _has_vaccination_access(profile, animal: Animal) -> bool:
- """Return True if profile may read/write vaccinations on this animal."""
- if not user_can_access_animal(profile, animal):
+ """Return True if profile may write (add/edit/delete) vaccinations on this animal.
+
+ Uses user_can_modify_animal so deceased animals are blocked for everyone,
+ including the owner.
+ """
+ if not user_can_modify_animal(profile, animal):
return False
if is_animal_owner(profile, animal):
return True
diff --git a/src/ahc/apps/users/models.py b/src/ahc/apps/users/models.py
index 127c031..9c86449 100644
--- a/src/ahc/apps/users/models.py
+++ b/src/ahc/apps/users/models.py
@@ -28,10 +28,12 @@ def __str__(self):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
-
- img = Image.open(self.profile_image.path)
-
- if any([img.height > 300, img.width > 300]):
- output_size = (300, 300)
- img.thumbnail(output_size)
+ if not self.profile_image:
+ return
+ try:
+ img = Image.open(self.profile_image.path)
+ except FileNotFoundError, OSError:
+ return
+ if img.height > 300 or img.width > 300:
+ img.thumbnail((300, 300))
img.save(self.profile_image.path)
diff --git a/src/ahc/apps/users/signals.py b/src/ahc/apps/users/signals.py
index 70eb8d5..710e5c1 100644
--- a/src/ahc/apps/users/signals.py
+++ b/src/ahc/apps/users/signals.py
@@ -27,6 +27,10 @@ def create_profile(sender, instance, created, **kwargs):
@receiver(post_save, sender=User)
-def save_profile(sender, instance, **kwargs):
+def save_profile(sender, instance, created, update_fields=None, **kwargs):
+ if created:
+ return
+ if update_fields is not None and set(update_fields) <= {"last_login", "date_joined"}:
+ return
if hasattr(instance, "profile"):
instance.profile.save()
diff --git a/src/ahc/apps/users/tests.py b/src/ahc/apps/users/tests.py
index 10ec676..9b6cc41 100644
--- a/src/ahc/apps/users/tests.py
+++ b/src/ahc/apps/users/tests.py
@@ -112,8 +112,8 @@ def test_valid_post_creates_user_and_redirects_to_login(self):
{
"username": "brandnewuser",
"email": "newuser@example.com",
- "password1": "Str0ng_P@ssw0rd!",
- "password2": "Str0ng_P@ssw0rd!",
+ "password1": "test-fixture-password-1",
+ "password2": "test-fixture-password-1",
},
)
assert response.status_code == 302
diff --git a/static/js/biometric_batch.js b/static/js/biometric_batch.js
new file mode 100644
index 0000000..bff2efb
--- /dev/null
+++ b/static/js/biometric_batch.js
@@ -0,0 +1,24 @@
+document.addEventListener('DOMContentLoaded', function () {
+ var recordTypeField = document.getElementById('id_record_type');
+ if (!recordTypeField) return;
+
+ var unitRow = document.getElementById('field_unit');
+ var customNameRow = document.getElementById('field_custom_name');
+ var customUnitRow = document.getElementById('field_custom_unit');
+
+ function toggleSessionFields() {
+ var type = recordTypeField.value;
+ if (type === 'custom') {
+ if (unitRow) unitRow.style.display = 'none';
+ if (customNameRow) customNameRow.style.display = 'block';
+ if (customUnitRow) customUnitRow.style.display = 'block';
+ } else {
+ if (unitRow) unitRow.style.display = 'block';
+ if (customNameRow) customNameRow.style.display = 'none';
+ if (customUnitRow) customUnitRow.style.display = 'none';
+ }
+ }
+
+ toggleSessionFields();
+ recordTypeField.addEventListener('change', toggleSessionFields);
+});
diff --git a/uv.lock b/uv.lock
index 0646ed7..a1e0af3 100644
--- a/uv.lock
+++ b/uv.lock
@@ -91,6 +91,7 @@ name = "animals-healthcare-application"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
+ { name = "aiohttp" },
{ name = "celery" },
{ name = "cffi" },
{ name = "cryptography" },
@@ -130,6 +131,7 @@ dev = [
[package.metadata]
requires-dist = [
+ { name = "aiohttp", specifier = ">=3" },
{ name = "celery", specifier = ">=5.4" },
{ name = "cffi", specifier = ">=1.17" },
{ name = "cryptography", specifier = ">=43" },