Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions src/ahc/apps/animals/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
71 changes: 71 additions & 0 deletions src/ahc/apps/animals/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@

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,
is_pinned,
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 (
Expand Down Expand Up @@ -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)
146 changes: 146 additions & 0 deletions src/ahc/apps/medical_notes/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
21 changes: 19 additions & 2 deletions src/ahc/apps/medical_notes/views/mixins/user_animal_permisions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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."""

Expand Down
9 changes: 9 additions & 0 deletions src/ahc/apps/medical_notes/views/type_basic_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
Expand Down
4 changes: 2 additions & 2 deletions src/ahc/apps/medical_notes/views/type_measurement_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading