diff --git a/pyproject.toml b/pyproject.toml index c498586..0071303 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "python-dateutil", "pyjwt", "defusedxml", + "aiohttp>=3", ] [dependency-groups] diff --git a/src/ahc/apps/animals/forms.py b/src/ahc/apps/animals/forms.py index ee9e217..e5f5bad 100644 --- a/src/ahc/apps/animals/forms.py +++ b/src/ahc/apps/animals/forms.py @@ -24,7 +24,10 @@ def __init__(self, *args, **kwargs): def clean_full_name(self): full_name = self.cleaned_data.get("full_name") - if Animal.objects.filter(Q(full_name=full_name) & (Q(owner=self.user) | Q(allowed_users=self.user))).exists(): + # Only check living animals so a deceased pet's name can be reused for a new one. + if Animal.objects.filter( + Q(full_name=full_name) & (Q(owner=self.user) | Q(allowed_users=self.user)) & Q(date_of_death__isnull=True) + ).exists(): raise forms.ValidationError("An animal with that name is already in your care.") return full_name diff --git a/src/ahc/apps/animals/migrations/0006_animal_date_of_death_animal_memorial_note.py b/src/ahc/apps/animals/migrations/0006_animal_date_of_death_animal_memorial_note.py new file mode 100644 index 0000000..d144066 --- /dev/null +++ b/src/ahc/apps/animals/migrations/0006_animal_date_of_death_animal_memorial_note.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.5 on 2026-06-04 19:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("animals", "0005_add_vaccination_share_category"), + ] + + operations = [ + migrations.AddField( + model_name="animal", + name="date_of_death", + field=models.DateField(blank=True, default=None, null=True), + ), + migrations.AddField( + model_name="animal", + name="memorial_note", + field=models.CharField(blank=True, default=None, max_length=2500, null=True), + ), + ] diff --git a/src/ahc/apps/animals/models.py b/src/ahc/apps/animals/models.py index f6f6552..8462e61 100644 --- a/src/ahc/apps/animals/models.py +++ b/src/ahc/apps/animals/models.py @@ -54,6 +54,14 @@ class Animal(models.Model): sex = models.CharField(max_length=1, choices=Sex.choices, default=None, blank=True, null=True) sterilization = models.BooleanField(default=False) + date_of_death = models.DateField(default=None, blank=True, null=True) + memorial_note = models.CharField(max_length=2500, default=None, blank=True, null=True) + + @property + def is_deceased(self) -> bool: + """Return True if a date of death has been recorded for this animal.""" + return self.date_of_death is not None + class AnimalShare(models.Model): """Through model for Animal.allowed_users — stores per-share access scope and expiry.""" diff --git a/src/ahc/apps/animals/selectors.py b/src/ahc/apps/animals/selectors.py index c09ef5f..7ccc8cf 100644 --- a/src/ahc/apps/animals/selectors.py +++ b/src/ahc/apps/animals/selectors.py @@ -12,10 +12,15 @@ def _today() -> date: def animals_visible_to(profile) -> QuerySet[Animal]: - """Return all animals accessible to the given profile (owner or active keeper).""" + """Return all living animals accessible to the given profile (owner or active keeper). + + Deceased animals are excluded unconditionally — they are only accessible to the + owner via deceased_animals_for(). + """ today = _today() return ( - Animal.objects.filter( + Animal.objects.filter(date_of_death__isnull=True) + .filter( Q(owner=profile) | Q(shares__carer=profile, shares__valid_until__isnull=True) | Q(shares__carer=profile, shares__valid_until__gte=today) @@ -25,6 +30,14 @@ def animals_visible_to(profile) -> QuerySet[Animal]: ) +def deceased_animals_for(profile) -> QuerySet[Animal]: + """Return deceased animals owned by the profile, ordered by most recently deceased first. + + Carers are intentionally excluded: death withdraws management to the owner only. + """ + return Animal.objects.filter(owner=profile, date_of_death__isnull=False).order_by("-date_of_death") + + def active_share_for(profile, animal: Animal) -> AnimalShare | None: """Return the non-expired AnimalShare for this profile/animal pair, or None.""" today = _today() @@ -35,13 +48,41 @@ def active_share_for(profile, animal: Animal) -> AnimalShare | None: return share if share.is_active(today) else None -def user_can_access_animal(profile, animal: Animal) -> bool: - """Return True if the profile is the owner or holds an active (non-expired) share.""" +def user_can_view_animal(profile, animal: Animal) -> bool: + """Return True if the profile may view this animal (read-only access). + + The owner may always view — including deceased animals (read-only archive). + Carers may only view a living animal with an active, non-expired share. + """ + if animal.owner == profile: + return True + if animal.date_of_death is not None: + return False # death withdraws all non-owner access + return active_share_for(profile, animal) is not None + + +def user_can_modify_animal(profile, animal: Animal) -> bool: + """Return True if the profile may write to this animal or its records. + + No writes are allowed on a deceased animal — not even by the owner. + The only permitted mutations on a deceased animal (editing the memorial note and + un-archiving) use is_animal_owner directly, bypassing this predicate by design. + """ + if animal.date_of_death is not None: + return False if animal.owner == profile: return True return active_share_for(profile, animal) is not None +def user_can_access_animal(profile, animal: Animal) -> bool: + """Alias for user_can_view_animal kept for backward compatibility. + + Prefer user_can_view_animal (read contexts) or user_can_modify_animal (write contexts). + """ + return user_can_view_animal(profile, animal) + + def is_animal_owner(profile, animal: Animal) -> bool: """Return True if the profile is the owner of the animal.""" return animal.owner == profile @@ -51,11 +92,14 @@ def allowed_categories_for(profile, animal: Animal) -> set[str]: """Return the set of ShareCategory values the profile may see. Owners get all categories. Carers get only what their active share grants. + Carers always get an empty set on a deceased animal (death withdraws all carer access). An empty set means no data-category access (animal page itself still blocked - upstream by user_can_access_animal). + upstream by user_can_view_animal). """ if is_animal_owner(profile, animal): return {c.value for c in ShareCategory} + if animal.date_of_death is not None: + return set() share = active_share_for(profile, animal) if share is None: return set() @@ -68,6 +112,17 @@ def get_or_create_share_defaults(profile) -> ShareDefaults: return defaults +def animals_for_biometric_batch(profile) -> QuerySet[Animal]: + """Return all animals the profile may include in a batch biometric session. + + Currently mirrors animals_visible_to (owner or active share with any access), + matching the permission level of the single-record BiometricRecordCreateView. + TODO: narrow to allow_biometrics=True for carer shares once that flag is enforced + consistently across single-record creation too. + """ + return animals_visible_to(profile) + + def is_pinned(profile, animal: Animal) -> bool: """Return True if the animal is currently pinned by the given profile.""" return profile.pinned_animals.filter(pk=animal.pk).exists() diff --git a/src/ahc/apps/animals/services.py b/src/ahc/apps/animals/services.py index 6269940..413ed0e 100644 --- a/src/ahc/apps/animals/services.py +++ b/src/ahc/apps/animals/services.py @@ -130,3 +130,32 @@ def set_animal_details( def remove_keeper(animal: Animal, keeper_id) -> None: """Remove a keeper from the animal's shares by Profile PK.""" AnimalShare.objects.filter(animal=animal, carer_id=keeper_id).delete() + + +def set_deceased(animal: Animal, date_of_death: date, memorial_note: str | None) -> None: + """Record an animal as deceased. + + AnimalShare rows are intentionally left intact (soft withdrawal). The deceased + gate in the selectors makes all shares inert while date_of_death is set, and + un-archiving with unset_deceased instantly restores the prior access configuration. + """ + animal.date_of_death = date_of_death + animal.memorial_note = memorial_note + animal.save() + + +def set_memorial_note(animal: Animal, memorial_note: str | None) -> None: + """Update the memorial note on a deceased animal without changing the death date.""" + animal.memorial_note = memorial_note + animal.save() + + +def unset_deceased(animal: Animal) -> None: + """Reverse an archiving action — the animal becomes living again. + + The memorial_note is preserved so that re-archiving retains historical context. + Existing AnimalShare rows are immediately effective again because the deceased gate + only checks date_of_death__isnull. + """ + animal.date_of_death = None + animal.save() diff --git a/src/ahc/apps/animals/templates/animals/all_animals_stable.html b/src/ahc/apps/animals/templates/animals/all_animals_stable.html index ef61539..4fed3e1 100644 --- a/src/ahc/apps/animals/templates/animals/all_animals_stable.html +++ b/src/ahc/apps/animals/templates/animals/all_animals_stable.html @@ -21,6 +21,7 @@

All pets:

Operations:

Add animal + In memoriam archive
{% endif %} diff --git a/src/ahc/apps/animals/templates/animals/archive.html b/src/ahc/apps/animals/templates/animals/archive.html new file mode 100644 index 0000000..a207504 --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/archive.html @@ -0,0 +1,29 @@ +{% extends 'homepage/base.html' %} +{% load static %} +{% block extra_css %} + +{% endblock %} +{% block content %} + + {% if user.is_authenticated %} + +
+

In memoriam

+ {% if animals %} +
+ {% for animal in animals %} + {% include "partials/animal_card.html" %} + {% endfor %} +
+ {% else %} +

No archived animals.

+ {% endif %} +
+ +
+ Back to all pets +
+ + {% endif %} + +{% endblock %} diff --git a/src/ahc/apps/animals/templates/animals/change_deceased.html b/src/ahc/apps/animals/templates/animals/change_deceased.html new file mode 100644 index 0000000..3f6ea98 --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/change_deceased.html @@ -0,0 +1,16 @@ +{% extends "homepage/base.html" %} +{% load static %} + +{% block content %} +
+

Archive animal as deceased

+

Recording a date of death will archive this animal. Carers will lose access immediately. You can restore the animal at any time from the profile page.

+
+ {% csrf_token %} + {% include "partials/form_fields.html" %} + +
+
+ Back to Settings +
+{% endblock %} diff --git a/src/ahc/apps/animals/templates/animals/change_memorial_note.html b/src/ahc/apps/animals/templates/animals/change_memorial_note.html new file mode 100644 index 0000000..1d2da1b --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/change_memorial_note.html @@ -0,0 +1,15 @@ +{% extends "homepage/base.html" %} +{% load static %} + +{% block content %} +
+

Edit memorial note

+
+ {% csrf_token %} + {% include "partials/form_fields.html" %} + +
+
+ Back to profile +
+{% endblock %} diff --git a/src/ahc/apps/animals/templates/animals/profile.html b/src/ahc/apps/animals/templates/animals/profile.html index 6482dd2..8d17595 100644 --- a/src/ahc/apps/animals/templates/animals/profile.html +++ b/src/ahc/apps/animals/templates/animals/profile.html @@ -38,12 +38,37 @@ {% block content %}
+ {% if is_deceased %} +
+
+

In memoriam — {{ animal.date_of_death }}

+ {% if animal.memorial_note %}

{{ animal.memorial_note }}

{% endif %} +
+ {% if is_owner %} +
+ Edit memorial note +
+ {% csrf_token %} + +
+
+ {% endif %} +
+ {% endif %} +
+ {% if is_deceased %} +
+ Animal's profile picture +
+ {% else %} Animal's profile picture + {% 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.

+ +
+ {% csrf_token %} + +
+ Session settings + {% include "partials/form_fields.html" with form=session_form %} +
+ + {{ formset.management_form }} + + {% if rows %} + + + + + + + + + + {% for row_form, animal in rows %} + + + + + + {% endfor %} + +
IncludeAnimalValue
{{ row_form.include }} + {{ row_form.animal_id }} + {{ animal.full_name }} + + {{ row_form.value }} + {% for error in row_form.value.errors %} + {{ error }} + {% endfor %} + {% for error in row_form.non_field_errors %} + {{ error }} + {% endfor %} +
+ {% else %} +

No animals available. Add an animal first, or ask an owner to share one with you.

+ {% endif %} + + +
+ +
+ + 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" },