+
{% if animals %}
-
-
All pets:
- {% for animal in animals %}
- {% include "partials/animal_card.html" %}
- {% endfor %}
+ {% for animal in animals %}
+ {% include "partials/animal_card.html" %}
+ {% endfor %}
{% endif %}
-
+
Operations:
+
{% endif %}
{% endblock %}
diff --git a/src/ahc/apps/animals/templates/animals/change_animal_details.html b/src/ahc/apps/animals/templates/animals/change_animal_details.html
new file mode 100644
index 0000000..4b9e4b1
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/change_animal_details.html
@@ -0,0 +1,15 @@
+{% extends "homepage/base.html" %}
+{% load static %}
+
+{% block content %}
+
+
Edit animal details
+
+
+
Back to Settings
+
+{% endblock %}
diff --git a/src/ahc/apps/animals/templates/animals/change_birthday.html b/src/ahc/apps/animals/templates/animals/change_birthday.html
index 5109645..d093403 100644
--- a/src/ahc/apps/animals/templates/animals/change_birthday.html
+++ b/src/ahc/apps/animals/templates/animals/change_birthday.html
@@ -1,12 +1,15 @@
{% extends "homepage/base.html" %}
-{% load crispy_forms_tags %}
-
{% block content %}
-
Return to profile
{% endblock %}
diff --git a/src/ahc/apps/animals/templates/animals/change_dietary_restrictions.html b/src/ahc/apps/animals/templates/animals/change_dietary_restrictions.html
new file mode 100644
index 0000000..98fb088
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/change_dietary_restrictions.html
@@ -0,0 +1,25 @@
+{% extends "homepage/base.html" %}
+{% load static %}
+
+{% block content %}
+
+
Dietary restrictions
+ {% if current_restrictions %}
+
+
+
+
{{ current_restrictions }}
+
+
+ {% else %}
+
No dietary restrictions set yet.
+ {% endif %}
+
+
+
Back to Diet
+
+{% endblock %}
diff --git a/src/ahc/apps/animals/templates/animals/change_first_contact.html b/src/ahc/apps/animals/templates/animals/change_first_contact.html
index 4ef1549..b994a41 100644
--- a/src/ahc/apps/animals/templates/animals/change_first_contact.html
+++ b/src/ahc/apps/animals/templates/animals/change_first_contact.html
@@ -1,6 +1,4 @@
{% extends "homepage/base.html" %}
-{% load crispy_forms_tags %}
-
{% block content %}
Current first contact vet:
@@ -12,11 +10,22 @@
Current first contact medical place:
-
Return to profile
{% endblock %}
diff --git a/src/ahc/apps/animals/templates/animals/change_next_visit.html b/src/ahc/apps/animals/templates/animals/change_next_visit.html
new file mode 100644
index 0000000..ca9ddfc
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/change_next_visit.html
@@ -0,0 +1,20 @@
+{% extends "homepage/base.html" %}
+{% load static %}
+
+{% block content %}
+
+
Set next vet visit
+ {% if next_visit_date %}
+
Current next visit: {{ next_visit_date|date:"Y-m-d" }}
+ {% else %}
+
No next visit date set.
+ {% endif %}
+
+
+
Back to Vet & Visits
+
+{% endblock %}
diff --git a/src/ahc/apps/animals/templates/animals/change_owner.html b/src/ahc/apps/animals/templates/animals/change_owner.html
index aa4fbb7..48e5910 100644
--- a/src/ahc/apps/animals/templates/animals/change_owner.html
+++ b/src/ahc/apps/animals/templates/animals/change_owner.html
@@ -1,25 +1,26 @@
{% extends "homepage/base.html" %}
-{% load crispy_forms_tags %}
-
{% block content %}
-
-
+
+
+ Return to Pet profile or Stable
+
{% endblock %}
diff --git a/src/ahc/apps/animals/templates/animals/create.html b/src/ahc/apps/animals/templates/animals/create.html
index f2fc482..533be19 100644
--- a/src/ahc/apps/animals/templates/animals/create.html
+++ b/src/ahc/apps/animals/templates/animals/create.html
@@ -1,21 +1,17 @@
{% extends "homepage/base.html" %}
-{% load crispy_forms_tags %}
{% block content %}
-
+
+
+ Already registered? Manage them all!
+
{% endblock %}
diff --git a/src/ahc/apps/animals/templates/animals/edit_share.html b/src/ahc/apps/animals/templates/animals/edit_share.html
new file mode 100644
index 0000000..5bdeeec
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/edit_share.html
@@ -0,0 +1,10 @@
+{% extends "homepage/base.html" %}
+{% block content %}
+
Edit access for {{ carer_name }}
+
+
Return to ownership
+{% endblock %}
diff --git a/src/ahc/apps/animals/templates/animals/image.html b/src/ahc/apps/animals/templates/animals/image.html
index ef53601..cbe7fe6 100644
--- a/src/ahc/apps/animals/templates/animals/image.html
+++ b/src/ahc/apps/animals/templates/animals/image.html
@@ -1,12 +1,9 @@
{% extends "homepage/base.html" %}
-{% load crispy_forms_tags %}
-
{% block content %}
-
-
-
Return to profile
+
+
Return to profile
{% endblock %}
diff --git a/src/ahc/apps/animals/templates/animals/manage_keepers.html b/src/ahc/apps/animals/templates/animals/manage_keepers.html
index 87c8209..eee1e4f 100644
--- a/src/ahc/apps/animals/templates/animals/manage_keepers.html
+++ b/src/ahc/apps/animals/templates/animals/manage_keepers.html
@@ -1,34 +1,27 @@
{% extends "homepage/base.html" %}
-{% load crispy_forms_tags %}
-
{% block content %}
-
-
-
-
- {% if allowed_users %}
+
Add a keeper for {{ full_name }}
+
+
+ {% if shares %}
+
Current keepers
+
+ {% for share in shares %}
+ -
+ {{ share.carer }}
+ {% if share.valid_until %} — expires {{ share.valid_until }}{% else %} — no expiry{% endif %}
+
+ {% endfor %}
+
+ {% endif %}
-
-
Current Keepers:
-
- {% for user in allowed_users %}
- - {{ user }}
- {% endfor %}
-
- {% endif %}
-
-
-
+
+
Return to Pet profile or Stable
{% endblock %}
diff --git a/src/ahc/apps/animals/templates/animals/profile.html b/src/ahc/apps/animals/templates/animals/profile.html
index b5b146f..6482dd2 100644
--- a/src/ahc/apps/animals/templates/animals/profile.html
+++ b/src/ahc/apps/animals/templates/animals/profile.html
@@ -1,146 +1,75 @@
{% extends "homepage/base.html" %}
-{% load crispy_forms_tags %}
{% load static %}
{% load custom_timesince %}
-{% block content %}
-
-
-
-
-
-
-
-
-
-
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
- incididunt ut labore et dolore magna aliqua.
-
-
-
-
-
-
{{ animal.first_contact_vet }}
-
{{ animal.first_contact_medical_place }}
-
-
-
-
-
-
Last records:
-
-
-
+{% block tab_nav %}
+
+{% endblock %}
-
+{% block content %}
+
-
+
+
+
+
+
+
{{ animal.full_name }}
-
- {% for record in recent_records reversed %}
- -
-
-
- {% endfor %}
-
-
-
-
-
-
-
Manage details:
+ {% if animal.species or animal.breed %}
+
{{ animal.species }}{% if animal.species and animal.breed %} / {% endif %}{{ animal.breed }}
+ {% endif %}
+ {% if animal.sex %}
+
Sex: {{ animal.get_sex_display }}
+ {% endif %}
+
Sterilized:
-
-
Common options:
-
+ {% if animal.birthdate %}
+
Age: {{ animal.birthdate|years_and_months_since:now }}
+
Next birthday: {{ animal.birthdate|date:"d-m" }}-{{ now|date:"Y" }}
+ {% endif %}
+
+
Owner: {{ animal.owner }}
+ {% if animal.short_description %}
+
{{ animal.short_description }}
+ {% endif %}
- {% if is_owner %}
-
-
-
Owner options
-
Records
-
-
-
Profile
-
-
-
Ownership
-
-
- {% endif %}
+
+
+ {% include active_partial %}
+
{% endblock %}
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_diet.html b/src/ahc/apps/animals/templates/animals/tabs/_diet.html
new file mode 100644
index 0000000..6cb8f23
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/tabs/_diet.html
@@ -0,0 +1,53 @@
+{% load static %}
+
+
+ Diet
+
+ {% if "diet" in allowed_categories %}
+
+ {% if animal.dietary_restrictions %}
+
+
+
+
{{ animal.dietary_restrictions }}
+
+
+ {% endif %}
+
+
+
+
+ {% if diet_records %}
+
+
+ {% for record in diet_records %}
+ -
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+ No diet notes recorded yet.
+ {% endif %}
+
+ {% else %}
+ You do not have access to this section.
+ {% endif %}
+
+
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_mainpage.html b/src/ahc/apps/animals/templates/animals/tabs/_mainpage.html
new file mode 100644
index 0000000..40d34d4
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/tabs/_mainpage.html
@@ -0,0 +1,32 @@
+{% load static %}
+
+
+ {% if "basic" in allowed_categories %}
+
+
+
+
+ {{ animal.long_description|default:"No additional description." }}
+
+
+
+ {% endif %}
+
+
+
+
+
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_medications.html b/src/ahc/apps/animals/templates/animals/tabs/_medications.html
new file mode 100644
index 0000000..5fb1577
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/tabs/_medications.html
@@ -0,0 +1,48 @@
+{% load static %}
+
+
+ Medications
+
+ {% if "medications" in allowed_categories %}
+
+
+
+
+ {% if medication_records %}
+
+
+ {% for record in medication_records %}
+ -
+
+
+
{{ record.short_description }}
+ {% if record.date_event_ended %}
+
– {{ record.date_event_ended|date:"Y-m-d" }}
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+ No medication notes recorded yet.
+ {% endif %}
+
+ {% else %}
+ You do not have access to this section.
+ {% endif %}
+
+
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_notes.html b/src/ahc/apps/animals/templates/animals/tabs/_notes.html
new file mode 100644
index 0000000..38de6ce
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/tabs/_notes.html
@@ -0,0 +1,101 @@
+{% load static %}
+
+
+ {% if "history" in allowed_categories %}
+ Notes
+
+
+
+
Add a note
+
View full timeline
+ {% if available_months %}
+
+ {% endif %}
+
+
+
+ {% if other_records %}
+
+ {% for record in other_records %}
+ {% ifchanged record.date_creation|date:"Y-m" %}
+
+ {% endifchanged %}
+ -
+
+
+ {% endfor %}
+ {% if tl_has_more %}
+ -
+
+
+ {% endif %}
+
+
+ {% else %}
+ No notes recorded yet.
+ {% endif %}
+
+ {% endif %}
+
+ {% if "biometrics" in allowed_categories %}
+ Biometrics
+ {% if biometric_records %}
+
+
+ {% for record in biometric_records %}
+ -
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+ No biometric records yet.
+ {% endif %}
+ {% endif %}
+
+ {% if not "history" in allowed_categories and not "biometrics" in allowed_categories %}
+ You do not have access to this section.
+ {% endif %}
+
+
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_ownership.html b/src/ahc/apps/animals/templates/animals/tabs/_ownership.html
new file mode 100644
index 0000000..42d57e4
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/tabs/_ownership.html
@@ -0,0 +1,49 @@
+{% load static %}
+
+
+ Ownership
+ Current owner: {{ animal.owner }}
+ Change owner
+
+
+ Keepers
+ {% if keepers %}
+
+ {% for share in keepers %}
+ -
+
+
{{ share.carer }}
+
+ {% if share.valid_until %}
+ Expires: {{ share.valid_until }}
+ {% else %}
+ No expiry
+ {% endif %}
+
+
+ {% if share.allow_basic %}- Basic info
{% endif %}
+ {% if share.allow_vet_contact %}- Vet contact
{% endif %}
+ {% if share.allow_diet %}- Diet
{% endif %}
+ {% if share.allow_medications %}- Medications
{% endif %}
+ {% if share.allow_history %}- History & notes
{% endif %}
+ {% if share.allow_biometrics %}- Biometrics
{% endif %}
+
+
+
+
+ {% endfor %}
+
+ {% else %}
+ No keepers assigned yet.
+ {% endif %}
+
+ Add a keeper
+
+
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_settings.html b/src/ahc/apps/animals/templates/animals/tabs/_settings.html
new file mode 100644
index 0000000..ee0031e
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/tabs/_settings.html
@@ -0,0 +1,29 @@
+{% load static %}
+
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_vaccinations.html b/src/ahc/apps/animals/templates/animals/tabs/_vaccinations.html
new file mode 100644
index 0000000..2f1c348
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/tabs/_vaccinations.html
@@ -0,0 +1,40 @@
+{% load static %}
+
+
+ Vaccinations
+
+ {% if "vaccinations" in allowed_categories %}
+
+
+
+
+
+ | Vaccine name |
+ Last vaccinated |
+ Valid until |
+ Suggested clinic |
+ Reminder date |
+ Actions |
+
+
+
+ {% for vaccination in vaccination_records %}
+ {% include "medical_notes/partials/_vaccination_row.html" %}
+ {% endfor %}
+
+
+
+
+
+
+ {% else %}
+ You do not have access to this section.
+ {% endif %}
+
+
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_vet.html b/src/ahc/apps/animals/templates/animals/tabs/_vet.html
new file mode 100644
index 0000000..f97fec6
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/tabs/_vet.html
@@ -0,0 +1,96 @@
+{% load static %}
+
+
+ {% if "vet_contact" in allowed_categories %}
+ Veterinary contact
+
+
+
+
{{ animal.first_contact_vet|default:"Not set." }}
+
{{ animal.first_contact_medical_place|default:"Not set." }}
+
+
+
+ {% if animal.next_visit_date %}
+ Next scheduled visit: {{ animal.next_visit_date|date:"Y-m-d" }}
+ {% else %}
+ No next visit scheduled.
+ {% endif %}
+
+ {% if is_owner %}
+
+ {% endif %}
+
+ {% endif %}
+
+ {% if "history" in allowed_categories %}
+ Medical visit timeline
+
+
+
+
Add vet visit
+
View all visits
+ {% if available_months %}
+
+ {% endif %}
+
+
+
+ {% if vet_records %}
+
+ {% for record in vet_records %}
+ {% ifchanged record.date_creation|date:"Y-m" %}
+
+ {% endifchanged %}
+ -
+
+
+ {% endfor %}
+ {% if tl_has_more %}
+ -
+
+
+ {% endif %}
+
+
+ {% else %}
+ No medical visits recorded yet.
+ {% endif %}
+
+ {% endif %}
+
+ {% if not "vet_contact" in allowed_categories and not "history" in allowed_categories %}
+ You do not have access to this section.
+ {% endif %}
+
+
diff --git a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html
new file mode 100644
index 0000000..5371d83
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html
@@ -0,0 +1,31 @@
+{% for record in other_records %}
+{% ifchanged record.date_creation|date:"Y-m" %}
+
+{% endifchanged %}
+
+
+
+{% endfor %}
+{% if tl_has_more %}
+
+
+
+{% endif %}
diff --git a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html
new file mode 100644
index 0000000..b32a946
--- /dev/null
+++ b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html
@@ -0,0 +1,22 @@
+{% for record in vet_records %}
+{% ifchanged record.date_creation|date:"Y-m" %}
+
+{% endifchanged %}
+
+
+
+{% endfor %}
+{% if tl_has_more %}
+
+
+
+{% endif %}
diff --git a/src/ahc/apps/animals/tests.py b/src/ahc/apps/animals/tests.py
index 93d5b15..339df85 100644
--- a/src/ahc/apps/animals/tests.py
+++ b/src/ahc/apps/animals/tests.py
@@ -16,6 +16,7 @@
create_animal,
pin_animal,
process_profile_image,
+ remove_keeper,
set_birthday,
set_first_contact,
transfer_ownership,
@@ -97,29 +98,29 @@ def test_returns_false_for_non_owner(self):
@pytest.mark.unit
class TestUserCanAccessAnimalSelector:
- """user_can_access_animal: short-circuits on owner; queries allowed_users otherwise."""
+ """user_can_access_animal: short-circuits on owner; delegates to active_share_for otherwise."""
def test_owner_can_access(self):
profile = MagicMock()
animal = MagicMock()
animal.owner = profile
- assert user_can_access_animal(profile, animal) is True
- animal.allowed_users.filter.assert_not_called()
+ with patch("ahc.apps.animals.selectors.active_share_for") as mock_share:
+ assert user_can_access_animal(profile, animal) is True
+ mock_share.assert_not_called()
def test_keeper_can_access(self):
profile = MagicMock()
animal = MagicMock()
animal.owner = MagicMock()
- animal.allowed_users.filter.return_value.exists.return_value = True
- assert user_can_access_animal(profile, animal) is True
- animal.allowed_users.filter.assert_called_once_with(pk=profile.pk)
+ with patch("ahc.apps.animals.selectors.active_share_for", return_value=MagicMock()):
+ assert user_can_access_animal(profile, animal) is True
def test_stranger_cannot_access(self):
profile = MagicMock()
animal = MagicMock()
animal.owner = MagicMock()
- animal.allowed_users.filter.return_value.exists.return_value = False
- assert user_can_access_animal(profile, animal) is False
+ with patch("ahc.apps.animals.selectors.active_share_for", return_value=None):
+ assert user_can_access_animal(profile, animal) is False
@pytest.mark.unit
@@ -284,19 +285,20 @@ def test_adds_requesting_as_keeper_when_flag_is_set(self):
new_owner = MagicMock()
requesting = MagicMock()
- transfer_ownership(animal, new_owner, set_keeper=True, requesting_profile=requesting)
-
- animal.allowed_users.add.assert_called_once_with(requesting)
+ with patch("ahc.apps.animals.services.create_share") as mock_create_share:
+ transfer_ownership(animal, new_owner, set_keeper=True, requesting_profile=requesting)
+ mock_create_share.assert_called_once_with(animal, requesting.pk, scope=None, valid_until=None)
@pytest.mark.unit
class TestAddKeeperService:
- """add_keeper: delegates to M2M.add with the provided keeper id."""
+ """add_keeper: delegates to create_share with the provided keeper id and default scope."""
def test_adds_keeper_by_id(self):
animal = MagicMock()
- add_keeper(animal, 42)
- animal.allowed_users.add.assert_called_once_with(42)
+ with patch("ahc.apps.animals.services.create_share") as mock_create_share:
+ add_keeper(animal, 42)
+ mock_create_share.assert_called_once_with(animal, 42, scope=None, valid_until=None)
@pytest.mark.unit
@@ -316,3 +318,184 @@ def test_set_first_contact_assigns_both_fields_and_saves(self):
assert animal.first_contact_vet == "Dr Smith"
assert animal.first_contact_medical_place == "City Clinic"
animal.save.assert_called_once()
+
+
+@pytest.mark.unit
+class TestNewAnimalServices:
+ """remove_keeper / set_next_visit / set_dietary_restrictions: unit coverage."""
+
+ def test_remove_keeper_delegates_to_animalshare(self):
+ animal = MagicMock()
+ with patch("ahc.apps.animals.services.AnimalShare") as mock_model:
+ remove_keeper(animal, 99)
+ mock_model.objects.filter.assert_called_once_with(animal=animal, carer_id=99)
+ mock_model.objects.filter.return_value.delete.assert_called_once()
+
+ def test_set_next_visit_assigns_date_and_saves(self):
+ from datetime import date as date_type
+
+ from ahc.apps.animals.services import set_next_visit
+
+ animal = MagicMock()
+ d = date_type(2026, 9, 1)
+ set_next_visit(animal, d)
+ assert animal.next_visit_date == d
+ animal.save.assert_called_once()
+
+ def test_set_dietary_restrictions_assigns_text_and_saves(self):
+ from ahc.apps.animals.services import set_dietary_restrictions
+
+ animal = MagicMock()
+ set_dietary_restrictions(animal, "No grapes, no onions")
+ assert animal.dietary_restrictions == "No grapes, no onions"
+ animal.save.assert_called_once()
+
+ def test_remove_keeper_does_not_affect_owner(self):
+ """Removing a keeper must not touch the owner field."""
+ animal = MagicMock()
+ original_owner = MagicMock()
+ animal.owner = original_owner
+ with patch("ahc.apps.animals.services.AnimalShare"):
+ remove_keeper(animal, 42)
+ assert animal.owner is original_owner
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+class TestOtherRecordsForSelector:
+ """other_records_for: excludes medical_visit and diet_note types."""
+
+ @pytest.fixture
+ def animal(self, db, user_profile):
+ _, profile = user_profile
+ return Animal.objects.create(full_name="Buddy", owner=profile)
+
+ def test_excludes_medical_visit_and_diet_note(self, animal, user_profile):
+ from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord
+ from ahc.apps.medical_notes.selectors import other_records_for
+
+ _, profile = user_profile
+ MedicalRecord.objects.create(
+ animal=animal, author=profile, short_description="Visit", type_of_event="medical_visit"
+ )
+ MedicalRecord.objects.create(animal=animal, author=profile, short_description="Diet", type_of_event="diet_note")
+ note = MedicalRecord.objects.create(
+ animal=animal, author=profile, short_description="Other", type_of_event="fast_note"
+ )
+
+ results = list(other_records_for(animal))
+ ids = [r.id for r in results]
+ assert note.id in ids
+ assert len(results) == 1
+
+ def test_returns_empty_when_only_special_types(self, animal, user_profile):
+ from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord
+ from ahc.apps.medical_notes.selectors import other_records_for
+
+ _, profile = user_profile
+ MedicalRecord.objects.create(animal=animal, author=profile, short_description="V", type_of_event="medical_visit")
+ assert list(other_records_for(animal)) == []
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+class TestAnimalTabView:
+ """AnimalTabView: htmx vs full-page response, access control."""
+
+ @pytest.fixture
+ def animal(self, db, user_profile):
+ _, profile = user_profile
+ return Animal.objects.create(full_name="Luna", owner=profile)
+
+ def _client_for(self, user):
+ from django.test import Client
+
+ c = Client()
+ c.force_login(user)
+ return c
+
+ def test_htmx_request_returns_fragment_without_base_title(self, animal, user_profile):
+ user, _ = user_profile
+ c = self._client_for(user)
+ url = f"/pet/{animal.id}/tab/mainpage/"
+ response = c.get(url, HTTP_HX_REQUEST="true")
+ assert response.status_code == 200
+ content = response.content.decode()
+ assert "" not in content
+
+ def test_non_htmx_request_returns_full_page_with_base_title(self, animal, user_profile):
+ user, _ = user_profile
+ c = self._client_for(user)
+ url = f"/pet/{animal.id}/tab/mainpage/"
+ response = c.get(url)
+ assert response.status_code == 200
+ assert "" in response.content.decode()
+
+ def test_all_public_slugs_return_200_for_owner(self, animal, user_profile):
+ user, _ = user_profile
+ c = self._client_for(user)
+ for slug in ("mainpage", "vet", "diet", "notes", "ownership", "settings"):
+ url = f"/pet/{animal.id}/tab/{slug}/"
+ response = c.get(url, HTTP_HX_REQUEST="true")
+ assert response.status_code == 200, f"Expected 200 for slug={slug!r}, got {response.status_code}"
+
+ def test_owner_only_tabs_return_403_for_keeper(self, animal, second_user_profile):
+ other_user, other_profile = second_user_profile
+ animal.allowed_users.add(other_profile)
+ c = self._client_for(other_user)
+ for slug in ("ownership", "settings"):
+ url = f"/pet/{animal.id}/tab/{slug}/"
+ response = c.get(url, HTTP_HX_REQUEST="true")
+ assert response.status_code == 403, f"Expected 403 for keeper on slug={slug!r}, got {response.status_code}"
+
+ def test_non_accessible_user_gets_403(self, animal, second_user_profile):
+ other_user, _ = second_user_profile
+ c = self._client_for(other_user)
+ url = f"/pet/{animal.id}/tab/mainpage/"
+ response = c.get(url)
+ assert response.status_code == 403
+
+ def test_unknown_slug_returns_404(self, animal, user_profile):
+ user, _ = user_profile
+ c = self._client_for(user)
+ url = f"/pet/{animal.id}/tab/nonexistent/"
+ response = c.get(url)
+ assert response.status_code == 404
+
+
+@pytest.mark.integration
+@pytest.mark.django_db
+class TestRemoveKeeperView:
+ """RemoveKeeperView: owner POST removes keeper; non-owner gets 403."""
+
+ @pytest.fixture
+ def animal_with_keeper(self, db, user_profile, second_user_profile):
+ _, owner_profile = user_profile
+ _, keeper_profile = second_user_profile
+ a = Animal.objects.create(full_name="Rex", owner=owner_profile)
+ a.allowed_users.add(keeper_profile)
+ return a
+
+ def test_owner_post_removes_keeper_and_redirects(self, animal_with_keeper, user_profile, second_user_profile):
+ from django.test import Client
+
+ owner_user, _ = user_profile
+ _, keeper_profile = second_user_profile
+ c = Client()
+ c.force_login(owner_user)
+ url = f"/pet/{animal_with_keeper.id}/keepers/{keeper_profile.pk}/remove/"
+ response = c.post(url)
+ assert response.status_code == 302
+ animal_with_keeper.refresh_from_db()
+ assert not animal_with_keeper.allowed_users.filter(pk=keeper_profile.pk).exists()
+
+ def test_non_owner_post_returns_403(self, animal_with_keeper, second_user_profile):
+ from django.test import Client
+
+ keeper_user, _ = second_user_profile
+ c = Client()
+ c.force_login(keeper_user)
+ _, keeper_profile = second_user_profile
+ url = f"/pet/{animal_with_keeper.id}/keepers/{keeper_profile.pk}/remove/"
+ response = c.post(url)
+ assert response.status_code == 403
diff --git a/src/ahc/apps/animals/urls.py b/src/ahc/apps/animals/urls.py
index f636055..c9fb55b 100644
--- a/src/ahc/apps/animals/urls.py
+++ b/src/ahc/apps/animals/urls.py
@@ -10,8 +10,18 @@
path("/cnt/", animal_owner_views.ChangeFirstContactView.as_view(), name="animal_first_contact"), # TO change
path("/btd/", animal_owner_views.ChangeBirthdayView.as_view(), name="animal_birthday"),
path("/", animal_views.AnimalProfileDetailView.as_view(), name="animal_profile"),
+ path("/tab//", animal_views.AnimalTabView.as_view(), name="animal_tab"),
path("/upload-image/", animal_owner_views.ImageUploadView.as_view(), name="upload_image"),
path("/manage_keepers/", animal_owner_views.ManageKeepersView.as_view(), name="manage_keepers"),
+ path("/next-visit/", animal_owner_views.ChangeNextVisitView.as_view(), name="animal_next_visit"),
+ path(
+ "/dietary-restrictions/",
+ animal_owner_views.ChangeDietaryRestrictionsView.as_view(),
+ name="animal_dietary_restrictions",
+ ),
+ 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("animals/", animal_views.StableView.as_view(), name="animals_stable"),
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 6b75907..ed48e89 100644
--- a/src/ahc/apps/animals/utils_owner/forms.py
+++ b/src/ahc/apps/animals/utils_owner/forms.py
@@ -3,7 +3,7 @@
from django import forms
from PIL import Image
-from ahc.apps.animals.models import Animal
+from ahc.apps.animals.models import Animal, AnimalShare
from ahc.apps.users.models import Profile
@@ -60,10 +60,36 @@ def clean_new_owner(self):
class ManageKeepersForm(forms.Form):
input_user = forms.CharField(max_length=255, required=True, label="Full keeper profile name")
+ valid_until = forms.DateField(
+ required=False,
+ label="Access expires on (leave empty for indefinite)",
+ widget=forms.DateInput(attrs={"type": "date"}),
+ )
+ allow_basic = forms.BooleanField(required=False, label="Basic info")
+ allow_vet_contact = forms.BooleanField(required=False, label="Vet contact")
+ allow_diet = forms.BooleanField(required=False, label="Diet")
+ allow_medications = forms.BooleanField(required=False, label="Medications")
+ allow_history = forms.BooleanField(required=False, label="History & notes")
+ allow_biometrics = forms.BooleanField(required=False, label="Biometrics")
+ allow_vaccinations = forms.BooleanField(required=False, label="Vaccinations")
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("instance", None)
super().__init__(*args, **kwargs)
+ # Pre-fill category flags from the owner's share defaults.
+ from ahc.apps.animals.selectors import get_or_create_share_defaults
+
+ defaults = get_or_create_share_defaults(self.instance.owner)
+ for field in (
+ "allow_basic",
+ "allow_vet_contact",
+ "allow_diet",
+ "allow_medications",
+ "allow_history",
+ "allow_biometrics",
+ "allow_vaccinations",
+ ):
+ self.fields[field].initial = getattr(defaults, field)
def clean_input_user(self):
input_user = self.cleaned_data.get("input_user")
@@ -71,15 +97,40 @@ def clean_input_user(self):
if input_user == self.instance.owner.user.username:
raise forms.ValidationError("As the owner you can not set yourself as a keeper.")
- if input_user in self.instance.allowed_users.all():
+ if self.instance.shares.filter(carer__user__username=input_user).exists():
raise forms.ValidationError("User is already on the list of keepers.")
- if not Profile.objects.filter(user__username=input_user).exists():
+ profile = Profile.objects.filter(user__username=input_user).first()
+ if profile is None:
raise forms.ValidationError("User does not exist.")
- input_user_id = Profile.objects.filter(user__username=input_user).first().id
+ return profile.pk
- return input_user_id
+
+class EditShareForm(forms.ModelForm):
+ class Meta:
+ model = AnimalShare
+ fields = [
+ "valid_until",
+ "allow_basic",
+ "allow_vet_contact",
+ "allow_diet",
+ "allow_medications",
+ "allow_history",
+ "allow_biometrics",
+ "allow_vaccinations",
+ ]
+ widgets = {"valid_until": forms.DateInput(attrs={"type": "date"})}
+ labels = {
+ "valid_until": "Access expires on (leave empty for indefinite)",
+ "allow_basic": "Basic info",
+ "allow_vet_contact": "Vet contact",
+ "allow_diet": "Diet",
+ "allow_medications": "Medications",
+ "allow_history": "History & notes",
+ "allow_biometrics": "Biometrics",
+ "allow_vaccinations": "Vaccinations",
+ }
class ChangeBirthdayForm(forms.ModelForm):
@@ -92,7 +143,7 @@ def clean_birthdate(self):
birthdate = self.cleaned_data.get("birthdate")
current_date = date.today()
- if birthdate > current_date:
+ if birthdate is not None and birthdate > current_date:
raise forms.ValidationError("Date could not be set further than current day.")
return birthdate
@@ -106,3 +157,23 @@ class Meta:
"first_contact_vet": forms.Textarea(attrs={"rows": 4, "cols": 2}),
"first_contact_medical_place": forms.Textarea(attrs={"rows": 4, "cols": 2}),
}
+
+
+class ChangeNextVisitForm(forms.ModelForm):
+ class Meta:
+ model = Animal
+ fields = ["next_visit_date"]
+ widgets = {"next_visit_date": forms.DateInput(attrs={"type": "date"})}
+
+
+class ChangeDietaryRestrictionsForm(forms.ModelForm):
+ class Meta:
+ model = Animal
+ fields = ["dietary_restrictions"]
+ widgets = {"dietary_restrictions": forms.Textarea(attrs={"rows": 6, "cols": 2})}
+
+
+class ChangeAnimalDetailsForm(forms.ModelForm):
+ class Meta:
+ model = Animal
+ fields = ["species", "breed", "sex", "sterilization"]
diff --git a/src/ahc/apps/animals/utils_owner/views.py b/src/ahc/apps/animals/utils_owner/views.py
index 349a652..1e01b20 100644
--- a/src/ahc/apps/animals/utils_owner/views.py
+++ b/src/ahc/apps/animals/utils_owner/views.py
@@ -1,22 +1,31 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
-from django.views.generic import DeleteView
+from django.views.generic import DeleteView, View
from django.views.generic.edit import FormView
from ahc.apps.animals.mixins.animal_owner_permissions import UserPassesOwnershipTestMixin
-from ahc.apps.animals.models import Animal
+from ahc.apps.animals.models import Animal, AnimalShare
from ahc.apps.animals.services import (
- add_keeper,
+ create_share,
process_profile_image,
+ remove_keeper,
+ set_animal_details,
set_birthday,
+ set_dietary_restrictions,
set_first_contact,
+ set_next_visit,
transfer_ownership,
+ update_share,
)
from ahc.apps.animals.utils_owner.forms import (
+ ChangeAnimalDetailsForm,
ChangeBirthdayForm,
+ ChangeDietaryRestrictionsForm,
ChangeFirstContactForm,
+ ChangeNextVisitForm,
ChangeOwnerForm,
+ EditShareForm,
ImageUploadForm,
ManageKeepersForm,
)
@@ -92,7 +101,7 @@ def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
animal = Animal.objects.get(pk=self.kwargs["pk"])
context["full_name"] = animal.full_name
- context["allowed_users"] = animal.allowed_users.all()
+ context["shares"] = animal.shares.select_related("carer__user").all()
context["animal_url"] = reverse("animal_profile", kwargs={"pk": self.get_form().instance.id})
return context
@@ -102,7 +111,16 @@ def get_form_kwargs(self):
return kwargs
def form_valid(self, form):
- add_keeper(form.instance, form.cleaned_data["input_user"])
+ cd = form.cleaned_data
+ scope = {
+ "allow_basic": cd["allow_basic"],
+ "allow_vet_contact": cd["allow_vet_contact"],
+ "allow_diet": cd["allow_diet"],
+ "allow_medications": cd["allow_medications"],
+ "allow_history": cd["allow_history"],
+ "allow_biometrics": cd["allow_biometrics"],
+ }
+ create_share(form.instance, cd["input_user"], scope=scope, valid_until=cd.get("valid_until"))
return super().form_valid(form)
def get_success_url(self):
@@ -152,3 +170,114 @@ def form_valid(self, form):
def get_success_url(self):
return self.request.path
+
+
+class ChangeNextVisitView(LoginRequiredMixin, UserPassesOwnershipTestMixin, FormView):
+ form_class = ChangeNextVisitForm
+ template_name = "animals/change_next_visit.html"
+
+ def get_context_data(self, **kwargs):
+ animal = get_object_or_404(Animal, pk=self.kwargs["pk"])
+ context = super().get_context_data(**kwargs)
+ context["animal_id"] = self.kwargs["pk"]
+ context["next_visit_date"] = animal.next_visit_date
+ return context
+
+ def form_valid(self, form):
+ set_next_visit(
+ get_object_or_404(Animal, pk=self.kwargs["pk"]),
+ next_visit_date=form.cleaned_data["next_visit_date"],
+ )
+ success_url = reverse("animal_tab", kwargs={"pk": self.kwargs["pk"], "slug": "vet"})
+ return redirect(success_url)
+
+
+class ChangeDietaryRestrictionsView(LoginRequiredMixin, UserPassesOwnershipTestMixin, FormView):
+ form_class = ChangeDietaryRestrictionsForm
+ template_name = "animals/change_dietary_restrictions.html"
+
+ def get_context_data(self, **kwargs):
+ animal = get_object_or_404(Animal, pk=self.kwargs["pk"])
+ context = super().get_context_data(**kwargs)
+ context["animal_id"] = self.kwargs["pk"]
+ context["current_restrictions"] = animal.dietary_restrictions
+ return context
+
+ def form_valid(self, form):
+ set_dietary_restrictions(
+ get_object_or_404(Animal, pk=self.kwargs["pk"]),
+ restrictions=form.cleaned_data["dietary_restrictions"],
+ )
+ success_url = reverse("animal_tab", kwargs={"pk": self.kwargs["pk"], "slug": "diet"})
+ return redirect(success_url)
+
+
+class ChangeAnimalDetailsView(LoginRequiredMixin, UserPassesOwnershipTestMixin, FormView):
+ form_class = ChangeAnimalDetailsForm
+ template_name = "animals/change_animal_details.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_animal_details(
+ get_object_or_404(Animal, pk=self.kwargs["pk"]),
+ species=form.cleaned_data["species"],
+ breed=form.cleaned_data["breed"],
+ sex=form.cleaned_data["sex"],
+ sterilization=form.cleaned_data["sterilization"],
+ )
+ success_url = reverse("animal_tab", kwargs={"pk": self.kwargs["pk"], "slug": "settings"})
+ return redirect(success_url)
+
+
+class RemoveKeeperView(LoginRequiredMixin, UserPassesOwnershipTestMixin, View):
+ """Remove a single keeper from the animal's shares (owner-only, POST)."""
+
+ def post(self, request, pk, keeper_pk):
+ animal = get_object_or_404(Animal, pk=pk)
+ remove_keeper(animal, keeper_pk)
+ return redirect(reverse("animal_tab", kwargs={"pk": pk, "slug": "ownership"}))
+
+
+class EditShareView(LoginRequiredMixin, UserPassesOwnershipTestMixin, FormView):
+ """Edit the access scope and expiry date of an existing AnimalShare (owner-only)."""
+
+ template_name = "animals/edit_share.html"
+ form_class = EditShareForm
+
+ def _get_share(self) -> AnimalShare:
+ animal = get_object_or_404(Animal, pk=self.kwargs["pk"])
+ return get_object_or_404(AnimalShare, animal=animal, carer_id=self.kwargs["keeper_pk"])
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["instance"] = self._get_share()
+ return kwargs
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context["animal_id"] = self.kwargs["pk"]
+ share = self._get_share()
+ context["carer_name"] = share.carer.user.username
+ return context
+
+ def form_valid(self, form):
+ cd = form.cleaned_data
+ scope = {
+ "allow_basic": cd["allow_basic"],
+ "allow_vet_contact": cd["allow_vet_contact"],
+ "allow_diet": cd["allow_diet"],
+ "allow_medications": cd["allow_medications"],
+ "allow_history": cd["allow_history"],
+ "allow_biometrics": cd["allow_biometrics"],
+ }
+ update_share(form.instance, scope=scope, valid_until=cd.get("valid_until"))
+ return redirect(reverse("animal_tab", kwargs={"pk": self.kwargs["pk"], "slug": "ownership"}))
diff --git a/src/ahc/apps/animals/views.py b/src/ahc/apps/animals/views.py
index 5ecc377..85ad176 100644
--- a/src/ahc/apps/animals/views.py
+++ b/src/ahc/apps/animals/views.py
@@ -1,7 +1,15 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import date, datetime
+from typing import Any
+
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
-from django.http import JsonResponse
+from django.http import Http404, JsonResponse
from django.urls import reverse
from django.utils import timezone
+from django.utils.dateparse import parse_datetime
from django.views.generic import TemplateView, View
from django.views.generic.detail import DetailView
from django.views.generic.edit import FormView
@@ -9,15 +17,257 @@
from ahc.apps.animals.forms import AnimalRegisterForm, PinAnimalForm
from ahc.apps.animals.models import Animal
from ahc.apps.animals.selectors import (
+ allowed_categories_for,
animals_visible_to,
is_animal_owner,
is_pinned,
- recent_records_for,
user_can_access_animal,
)
from ahc.apps.animals.services import create_animal, pin_animal, unpin_animal
+@dataclass
+class Tab:
+ slug: str
+ label: str
+ template: str
+ owner_only: bool
+ build: Callable[..., dict[str, Any]]
+ categories: frozenset[str] = frozenset()
+
+
+def _build_mainpage(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]:
+ return {}
+
+
+_TIMELINE_PER_PAGE = 20
+
+
+def _timeline_boundary_from_month(month_param: str) -> datetime | None:
+ """Return the start of the month AFTER month_param as an aware local datetime.
+
+ Used to filter records for a month-jump: records with date_creation < boundary
+ start exactly at the end of the target month. Returns None on parse failure.
+ """
+ try:
+ target = date.fromisoformat(month_param + "-01")
+ except ValueError:
+ return None
+ first_of_next = date(target.year + 1, 1, 1) if target.month == 12 else date(target.year, target.month + 1, 1)
+ tz = timezone.get_current_timezone()
+ return timezone.make_aware(datetime(first_of_next.year, first_of_next.month, first_of_next.day, 0, 0, 0), tz)
+
+
+def _build_vet(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]:
+ ctx: dict[str, Any] = {}
+ if allowed is None or "vet_contact" in allowed:
+ ctx["show_vet_contact"] = True
+
+ if allowed is None or "history" in allowed:
+ from ahc.apps.medical_notes.selectors import available_months_for, timeline_for
+
+ qs = timeline_for(animal, type_of_event="medical_visit").order_by("-date_creation")
+
+ month_param = request.GET.get("month")
+ before_param = request.GET.get("before")
+
+ if month_param and not before_param:
+ boundary = _timeline_boundary_from_month(month_param)
+ if boundary:
+ qs = qs.filter(date_creation__lt=boundary)
+ elif before_param:
+ before_dt = parse_datetime(before_param)
+ if before_dt:
+ qs = qs.filter(date_creation__lt=before_dt)
+
+ records = list(qs[: _TIMELINE_PER_PAGE + 1])
+ tl_has_more = len(records) > _TIMELINE_PER_PAGE
+ if tl_has_more:
+ records = records[:_TIMELINE_PER_PAGE]
+
+ ctx.update(
+ {
+ "vet_records": records,
+ "tl_has_more": tl_has_more,
+ "tl_next_before": records[-1].date_creation.isoformat() if records else None,
+ "tl_slug": "vet",
+ "scroll_to_month": month_param or "",
+ "available_months": available_months_for(animal, type_of_event="medical_visit"),
+ }
+ )
+ return ctx
+
+
+def _build_diet(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]:
+ if allowed is not None and "diet" not in allowed:
+ return {}
+ from ahc.apps.medical_notes.selectors import timeline_for
+
+ return {
+ "diet_records": timeline_for(animal, type_of_event="diet_note").order_by("-date_creation"),
+ }
+
+
+def _build_medications(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]:
+ if allowed is not None and "medications" not in allowed:
+ return {}
+ from ahc.apps.medical_notes.selectors import medication_notes_for
+
+ return {"medication_records": medication_notes_for(animal)}
+
+
+def _build_notes(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]:
+ ctx: dict[str, Any] = {}
+
+ if allowed is None or "history" in allowed:
+ from ahc.apps.medical_notes.selectors import other_history_for
+
+ qs = other_history_for(animal)
+
+ month_param = request.GET.get("month")
+ before_param = request.GET.get("before")
+
+ if month_param and not before_param:
+ boundary = _timeline_boundary_from_month(month_param)
+ if boundary:
+ qs = qs.filter(date_creation__lt=boundary)
+ elif before_param:
+ before_dt = parse_datetime(before_param)
+ if before_dt:
+ qs = qs.filter(date_creation__lt=before_dt)
+
+ records = list(qs[: _TIMELINE_PER_PAGE + 1])
+ tl_has_more = len(records) > _TIMELINE_PER_PAGE
+ if tl_has_more:
+ records = records[:_TIMELINE_PER_PAGE]
+
+ available_months = list(
+ other_history_for(animal).datetimes(
+ "date_creation",
+ "month",
+ order="DESC",
+ tzinfo=timezone.get_current_timezone(),
+ )
+ )
+
+ ctx.update(
+ {
+ "other_records": records,
+ "tl_has_more": tl_has_more,
+ "tl_next_before": records[-1].date_creation.isoformat() if records else None,
+ "tl_slug": "notes",
+ "scroll_to_month": month_param or "",
+ "available_months": available_months,
+ }
+ )
+
+ if allowed is None or "biometrics" in allowed:
+ from ahc.apps.medical_notes.selectors import biometric_records_for
+
+ ctx["biometric_records"] = biometric_records_for(animal)
+
+ return ctx
+
+
+def _build_ownership(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]:
+ return {"keepers": animal.shares.select_related("carer__user").all()}
+
+
+def _build_settings(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]:
+ return {}
+
+
+def _build_vaccinations(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]:
+ if allowed is not None and "vaccinations" not in allowed:
+ return {}
+ from ahc.apps.medical_notes.selectors import vaccination_notes_for
+
+ return {"vaccination_records": vaccination_notes_for(animal)}
+
+
+TAB_REGISTRY: dict[str, Tab] = {
+ tab.slug: tab
+ for tab in [
+ Tab(
+ "mainpage",
+ "Overview",
+ "animals/tabs/_mainpage.html",
+ False,
+ _build_mainpage,
+ frozenset({"basic"}),
+ ),
+ Tab(
+ "vet",
+ "Vet & Visits",
+ "animals/tabs/_vet.html",
+ False,
+ _build_vet,
+ frozenset({"vet_contact", "history"}),
+ ),
+ Tab(
+ "diet",
+ "Diet",
+ "animals/tabs/_diet.html",
+ False,
+ _build_diet,
+ frozenset({"diet"}),
+ ),
+ Tab(
+ "medications",
+ "Medications",
+ "animals/tabs/_medications.html",
+ False,
+ _build_medications,
+ frozenset({"medications"}),
+ ),
+ Tab(
+ "notes",
+ "Notes",
+ "animals/tabs/_notes.html",
+ False,
+ _build_notes,
+ frozenset({"history", "biometrics"}),
+ ),
+ Tab(
+ "vaccinations",
+ "Vaccinations",
+ "animals/tabs/_vaccinations.html",
+ False,
+ _build_vaccinations,
+ frozenset({"vaccinations"}),
+ ),
+ Tab("ownership", "Ownership", "animals/tabs/_ownership.html", True, _build_ownership),
+ Tab("settings", "Settings", "animals/tabs/_settings.html", True, _build_settings),
+ ]
+}
+
+TABS_LIST: list[Tab] = list(TAB_REGISTRY.values())
+
+DEFAULT_TAB_SLUG = "mainpage"
+
+
+def _base_profile_context(request, animal: Animal) -> dict[str, Any]:
+ """Shared context for profile.html shell and AnimalTabView."""
+ profile = request.user.profile
+ owner = is_animal_owner(profile, animal)
+ allowed = allowed_categories_for(profile, animal)
+
+ def _tab_visible(tab: Tab) -> bool:
+ if tab.owner_only:
+ return owner
+ if not tab.categories:
+ return True
+ return owner or bool(tab.categories & allowed)
+
+ return {
+ "now": timezone.now().date(),
+ "is_owner": owner,
+ "is_pinned": is_pinned(profile, animal),
+ "allowed_categories": allowed,
+ "tabs": [t for t in TABS_LIST if _tab_visible(t)],
+ }
+
+
class CreateAnimalView(LoginRequiredMixin, FormView):
template_name = "animals/create.html"
form_class = AnimalRegisterForm
@@ -30,18 +280,23 @@ def form_valid(self, form):
class AnimalProfileDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView):
+ """Entry point for the animal profile page.
+
+ Renders the full shell (profile.html) with the default tab active.
+ Tab content is served by AnimalTabView when htmx requests a fragment.
+ """
+
model = Animal
template_name = "animals/profile.html"
context_object_name = "animal"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- profile = self.request.user.profile
- context["now"] = timezone.now().date()
- # only for button visibility, do not use as authentication
- context["is_owner"] = is_animal_owner(profile, self.object)
- context["is_pinned"] = is_pinned(profile, self.object)
- context["recent_records"] = recent_records_for(self.object)
+ base = _base_profile_context(self.request, self.object)
+ context.update(base)
+ context["active_tab"] = DEFAULT_TAB_SLUG
+ context["active_partial"] = TAB_REGISTRY[DEFAULT_TAB_SLUG].template
+ context.update(TAB_REGISTRY[DEFAULT_TAB_SLUG].build(self.request, self.object, allowed=base["allowed_categories"]))
return context
def test_func(self):
@@ -49,6 +304,58 @@ def test_func(self):
return user_can_access_animal(self.request.user.profile, animal)
+class AnimalTabView(LoginRequiredMixin, UserPassesTestMixin, DetailView):
+ """Serves individual tab content for the animal profile page.
+
+ When called with HX-Request header (htmx): returns only the tab fragment.
+ Without the header (direct navigation / JS disabled): returns the full shell
+ so progressive enhancement works — every tab has a real fallback URL.
+ """
+
+ model = Animal
+ context_object_name = "animal"
+
+ def _get_tab(self) -> Tab:
+ slug = self.kwargs.get("slug", "")
+ tab = TAB_REGISTRY.get(slug)
+ if tab is None:
+ raise Http404(f"Unknown tab slug: {slug!r}")
+ return tab
+
+ def test_func(self):
+ animal = self.get_object()
+ profile = self.request.user.profile
+ if not user_can_access_animal(profile, animal):
+ return False
+ tab = TAB_REGISTRY.get(self.kwargs.get("slug", ""))
+ if tab is None:
+ return True
+ if tab.owner_only:
+ return is_animal_owner(profile, animal)
+ if tab.categories and not is_animal_owner(profile, animal):
+ allowed = allowed_categories_for(profile, animal)
+ return bool(tab.categories & allowed)
+ return True
+
+ def get_template_names(self):
+ tab = self._get_tab()
+ if self.request.headers.get("HX-Request"):
+ if self.request.GET.get("load_more") and tab.slug in ("vet", "notes"):
+ return [f"animals/tabs/partials/_timeline_nodes_{tab.slug}.html"]
+ return [tab.template]
+ return ["animals/profile.html"]
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ tab = self._get_tab()
+ base = _base_profile_context(self.request, self.object)
+ context.update(base)
+ context["active_tab"] = tab.slug
+ context["active_partial"] = tab.template
+ context.update(tab.build(self.request, self.object, allowed=base["allowed_categories"]))
+ return context
+
+
class StableView(LoginRequiredMixin, TemplateView):
template_name = "animals/all_animals_stable.html"
diff --git a/src/ahc/apps/homepage/templates/homepage/base.html b/src/ahc/apps/homepage/templates/homepage/base.html
index 04838f5..a3bd83f 100644
--- a/src/ahc/apps/homepage/templates/homepage/base.html
+++ b/src/ahc/apps/homepage/templates/homepage/base.html
@@ -1,4 +1,3 @@
-{% load i18n %}
{% load static %}
@@ -6,38 +5,48 @@
- AHC
+ {% if title %}
+ {{ title }} — AHC
+ {% else %}
+ AHC app
+ {% endif %}
+
+
+
+
-
- {% if title %}
- {{ title }}
- {% else %}
- AHC app
- {% endif %}
-
+ {% block extra_css %}{% endblock %}
-