diff --git a/src/ahc/apps/animals/selectors.py b/src/ahc/apps/animals/selectors.py index 7ccc8cf..af082c8 100644 --- a/src/ahc/apps/animals/selectors.py +++ b/src/ahc/apps/animals/selectors.py @@ -112,15 +112,34 @@ def get_or_create_share_defaults(profile) -> ShareDefaults: return defaults +def user_can_record_biometrics(profile, animal: Animal) -> bool: + """Return True if the profile may create biometric records for this animal. + + Layers user_can_modify_animal (living animal + owner/active share) with the + BIOMETRICS share-category gate: owners always pass; carers need allow_biometrics=True. + """ + if not user_can_modify_animal(profile, animal): + return False + return ShareCategory.BIOMETRICS.value in allowed_categories_for(profile, animal) + + def animals_for_biometric_batch(profile) -> QuerySet[Animal]: - """Return all animals the profile may include in a batch biometric session. + """Return all living 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. + Owners get all their living animals; carers only those whose active share grants + allow_biometrics=True. Queryset-level mirror of user_can_record_biometrics. """ - return animals_visible_to(profile) + today = _today() + return ( + Animal.objects.filter(date_of_death__isnull=True) + .filter( + Q(owner=profile) + | Q(shares__carer=profile, shares__allow_biometrics=True, shares__valid_until__isnull=True) + | Q(shares__carer=profile, shares__allow_biometrics=True, shares__valid_until__gte=today) + ) + .distinct() + .order_by("-creation_date") + ) def is_pinned(profile, animal: Animal) -> bool: diff --git a/src/ahc/apps/animals/tests.py b/src/ahc/apps/animals/tests.py index 5b25c81..37c38a0 100644 --- a/src/ahc/apps/animals/tests.py +++ b/src/ahc/apps/animals/tests.py @@ -5,6 +5,7 @@ from ahc.apps.animals.models import Animal from ahc.apps.animals.selectors import ( + animals_for_biometric_batch, animals_visible_to, deceased_animals_for, is_animal_owner, @@ -12,6 +13,7 @@ recent_records_for, user_can_access_animal, user_can_modify_animal, + user_can_record_biometrics, user_can_view_animal, ) from ahc.apps.animals.services import ( @@ -1277,3 +1279,72 @@ def test_archive_view_excludes_other_owners_deceased(self, deceased_animal, seco other_user, _ = second_user_profile response = self._client_for(other_user).get("/pet/archive/") assert deceased_animal not in response.context["animals"] + + +@pytest.mark.integration +@pytest.mark.django_db +class TestUserCanRecordBiometrics: + """user_can_record_biometrics: biometric-category gate layered on top of modify access.""" + + def test_owner_can_record_biometrics(self, animal, user_profile): + _, profile = user_profile + assert user_can_record_biometrics(profile, animal) is True + + def test_carer_with_allow_biometrics_can_record(self, animal, second_user_profile): + from ahc.apps.animals.models import AnimalShare + + _, carer_profile = second_user_profile + AnimalShare.objects.create(animal=animal, carer=carer_profile, allow_biometrics=True) + assert user_can_record_biometrics(carer_profile, animal) is True + + def test_carer_without_allow_biometrics_blocked(self, animal, second_user_profile): + from ahc.apps.animals.models import AnimalShare + + _, carer_profile = second_user_profile + AnimalShare.objects.create(animal=animal, carer=carer_profile, allow_biometrics=False) + assert user_can_record_biometrics(carer_profile, animal) is False + + def test_stranger_without_share_blocked(self, animal, second_user_profile): + _, other_profile = second_user_profile + assert user_can_record_biometrics(other_profile, animal) is False + + def test_deceased_animal_blocked_even_for_owner(self, user_profile): + _, profile = user_profile + deceased = Animal.objects.create(full_name="Gone", owner=profile, date_of_death=date(2024, 1, 1)) + assert user_can_record_biometrics(profile, deceased) is False + + +@pytest.mark.integration +@pytest.mark.django_db +class TestAnimalsForBiometricBatch: + """animals_for_biometric_batch: owner always included; carer only with allow_biometrics.""" + + def test_owner_animal_included(self, animal, user_profile): + _, profile = user_profile + assert animal in animals_for_biometric_batch(profile) + + def test_carer_with_allow_biometrics_included(self, animal, second_user_profile): + from ahc.apps.animals.models import AnimalShare + + _, carer_profile = second_user_profile + AnimalShare.objects.create(animal=animal, carer=carer_profile, allow_biometrics=True) + assert animal in animals_for_biometric_batch(carer_profile) + + def test_carer_without_allow_biometrics_excluded(self, animal, second_user_profile): + from ahc.apps.animals.models import AnimalShare + + _, carer_profile = second_user_profile + AnimalShare.objects.create(animal=animal, carer=carer_profile, allow_biometrics=False) + assert animal not in animals_for_biometric_batch(carer_profile) + + def test_expired_share_with_allow_biometrics_excluded(self, animal, second_user_profile): + from ahc.apps.animals.models import AnimalShare + + _, carer_profile = second_user_profile + AnimalShare.objects.create(animal=animal, carer=carer_profile, allow_biometrics=True, valid_until=date(2020, 1, 1)) + assert animal not in animals_for_biometric_batch(carer_profile) + + def test_deceased_animal_excluded_for_owner(self, user_profile): + _, profile = user_profile + deceased = Animal.objects.create(full_name="Gone", owner=profile, date_of_death=date(2024, 1, 1)) + assert deceased not in animals_for_biometric_batch(profile) diff --git a/src/ahc/apps/medical_notes/tests.py b/src/ahc/apps/medical_notes/tests.py index 91a8ae1..7417627 100644 --- a/src/ahc/apps/medical_notes/tests.py +++ b/src/ahc/apps/medical_notes/tests.py @@ -1178,3 +1178,149 @@ def test_due_vaccination_reminders_excludes_deceased(self, setup): reminder_ids = {v.pk for v in reminders} assert living_vacc.pk in reminder_ids assert deceased_vacc.pk not in reminder_ids + + +@pytest.mark.integration +@pytest.mark.django_db +class TestBiometricBatchCarerPermissions: + """BiometricBatchCreateView: carer without allow_biometrics is blocked end-to-end.""" + + @pytest.fixture + def shared_animal_no_biometrics(self, db, user_profile, second_user_profile): + from ahc.apps.animals.models import Animal, AnimalShare + + _, owner_profile = user_profile + _, carer_profile = second_user_profile + animal = Animal.objects.create(full_name="SharedPet", owner=owner_profile) + AnimalShare.objects.create(animal=animal, carer=carer_profile, allow_biometrics=False) + return animal, carer_profile + + def test_carer_without_biometrics_gets_no_rows(self, client, second_user_profile, shared_animal_no_biometrics): + """GET must not offer the animal when carer lacks allow_biometrics.""" + carer_user, _ = second_user_profile + animal, _ = shared_animal_no_biometrics + client.force_login(carer_user) + response = client.get(reverse("biometric_batch")) + + assert response.status_code == 200 + offered_ids = {str(a.id) for _, a in response.context["rows"]} + assert str(animal.id) not in offered_ids + + def test_carer_without_biometrics_post_creates_no_records( + self, client, second_user_profile, shared_animal_no_biometrics + ): + """POST with a no-biometrics animal_id must produce zero records.""" + from ahc.apps.medical_notes.models.type_measurement_notes import BiometricRecord + + carer_user, _ = second_user_profile + animal, _ = shared_animal_no_biometrics + client.force_login(carer_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(animal.id), + "form-0-value": "5.0", + } + response = client.post(reverse("biometric_batch"), data) + + assert response.status_code == 302 + assert BiometricRecord.objects.count() == 0 + + +@pytest.mark.integration +@pytest.mark.django_db +class TestBiometricRecordCreateViewPermissions: + """BiometricRecordCreateView: allow_biometrics flag enforced at mixin level.""" + + @pytest.fixture + def animal_and_shell_note(self, db, user_profile): + from ahc.apps.animals.models import Animal + from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord + + _, owner_profile = user_profile + animal = Animal.objects.create(full_name="BioAnimal", owner=owner_profile) + shell = MedicalRecord.objects.create( + animal=animal, author=owner_profile, short_description="shell", type_of_event="biometric_record" + ) + return animal, shell + + def test_carer_without_biometrics_gets_403(self, client, second_user_profile, user_profile, animal_and_shell_note): + from ahc.apps.animals.models import AnimalShare + + carer_user, carer_profile = second_user_profile + animal, shell = animal_and_shell_note + AnimalShare.objects.create(animal=animal, carer=carer_profile, allow_biometrics=False) + client.force_login(carer_user) + + response = client.get(f"/note/{animal.id}/{shell.id}/medical_create/") + assert response.status_code == 403 + + def test_owner_can_access(self, client, user_profile, animal_and_shell_note): + owner_user, _ = user_profile + animal, shell = animal_and_shell_note + client.force_login(owner_user) + + response = client.get(f"/note/{animal.id}/{shell.id}/medical_create/") + assert response.status_code == 200 + + def test_carer_with_biometrics_can_access(self, client, second_user_profile, user_profile, animal_and_shell_note): + from ahc.apps.animals.models import AnimalShare + + carer_user, carer_profile = second_user_profile + animal, shell = animal_and_shell_note + AnimalShare.objects.create(animal=animal, carer=carer_profile, allow_biometrics=True) + client.force_login(carer_user) + + response = client.get(f"/note/{animal.id}/{shell.id}/medical_create/") + assert response.status_code == 200 + + +@pytest.mark.integration +@pytest.mark.django_db +class TestCreateNoteFormViewBiometricGate: + """CreateNoteFormView: biometric shell-note gate — carers need allow_biometrics; other types unaffected.""" + + @pytest.fixture + def animal_with_share(self, db, user_profile, second_user_profile): + from ahc.apps.animals.models import Animal, AnimalShare + + _, owner_profile = user_profile + _, carer_profile = second_user_profile + animal = Animal.objects.create(full_name="GatedAnimal", owner=owner_profile) + share = AnimalShare.objects.create(animal=animal, carer=carer_profile, allow_biometrics=False, allow_basic=True) + return animal, share, carer_profile + + def test_carer_without_biometrics_gets_403_on_biometric_type(self, client, second_user_profile, animal_with_share): + carer_user, _ = second_user_profile + animal, _, _ = animal_with_share + client.force_login(carer_user) + + response = client.get(f"/note/{animal.id}/create/?type_of_event=biometric_record") + assert response.status_code == 403 + + def test_carer_with_biometrics_can_create_shell_note(self, client, second_user_profile, animal_with_share): + carer_user, _ = second_user_profile + animal, share, _ = animal_with_share + share.allow_biometrics = True + share.save() + client.force_login(carer_user) + + response = client.get(f"/note/{animal.id}/create/?type_of_event=biometric_record") + assert response.status_code == 200 + + def test_carer_without_biometrics_can_create_other_note_types(self, client, second_user_profile, animal_with_share): + """allow_biometrics gate must not affect non-biometric note types.""" + carer_user, _ = second_user_profile + animal, _, _ = animal_with_share + client.force_login(carer_user) + + response = client.get(f"/note/{animal.id}/create/?type_of_event=fast_note") + assert response.status_code == 200 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 1f5716c..12b455b 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,11 +3,13 @@ Each mixin implements test_func by delegating to a selector from ahc.apps.medical_notes.selectors, keeping views free of inline permission logic. -Four access-level patterns exist: +Five 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). +- BiometricModifyMixin — pk in URL is an Animal UUID; grants biometric write access + (owner always; carer needs allow_biometrics=True on their active share). - 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. @@ -25,7 +27,7 @@ from django.shortcuts import get_object_or_404 from ahc.apps.animals.models import Animal -from ahc.apps.animals.selectors import user_can_modify_animal, user_can_view_animal +from ahc.apps.animals.selectors import user_can_modify_animal, user_can_record_biometrics, 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, @@ -68,6 +70,21 @@ def test_func(self): AnimalDirectAccessRequiredMixin = AnimalDirectViewMixin +class BiometricModifyMixin(UserPassesTestMixin): + """Allow biometric writes when pk is an Animal UUID and the profile may record biometrics. + + Owners always pass; carers need allow_biometrics=True on their active share. + Deceased animals are blocked for everyone (user_can_record_biometrics delegates to + user_can_modify_animal which enforces that invariant). + """ + + request: AuthenticatedRequest + + def test_func(self): + animal = get_object_or_404(Animal, id=self.kwargs.get("pk")) + return user_can_record_biometrics(self.request.user.profile, animal) + + class AnimalAccessRequiredMixin(UserPassesTestMixin): """Allow write access when pk in URL is a MedicalRecord UUID and the profile may write to its animal.""" 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 dfa6f15..0f73cdb 100644 --- a/src/ahc/apps/medical_notes/views/type_basic_note.py +++ b/src/ahc/apps/medical_notes/views/type_basic_note.py @@ -13,6 +13,7 @@ from django.views.generic.list import ListView from ahc.apps.animals.models import Animal as AnimalProfile +from ahc.apps.animals.selectors import user_can_record_biometrics from ahc.apps.medical_notes.forms.type_basic_note import ( MedicalRecordEditForm, MedicalRecordEditRelatedAnimalsForm, @@ -53,6 +54,14 @@ class CreateNoteFormView(LoginRequiredMixin, AnimalDirectModifyMixin, FormView): form_class = MedicalRecordForm request: AuthenticatedRequest + def test_func(self): + if not super().test_func(): + return False + if self.request.GET.get("type_of_event") == "biometric_record": + animal = get_object_or_404(AnimalProfile, id=self.kwargs.get("pk")) + return user_can_record_biometrics(self.request.user.profile, animal) + return True + def get_template_names(self): if self.request.headers.get("HX-Request"): return ["partials/modal_note_form.html"] 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 ea664e1..9333040 100644 --- a/src/ahc/apps/medical_notes/views/type_measurement_notes.py +++ b/src/ahc/apps/medical_notes/views/type_measurement_notes.py @@ -15,13 +15,13 @@ 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_batch_biometric_records, create_biometric_record -from ahc.apps.medical_notes.views.mixins.user_animal_permisions import AnimalDirectModifyMixin +from ahc.apps.medical_notes.views.mixins.user_animal_permisions import BiometricModifyMixin if TYPE_CHECKING: from ahc.types import AuthenticatedRequest -class BiometricRecordCreateView(LoginRequiredMixin, AnimalDirectModifyMixin, FormView): +class BiometricRecordCreateView(LoginRequiredMixin, BiometricModifyMixin, FormView): template_name = "medical_notes/create.html" form_class = BiometricRecordForm