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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies = [
"python-dateutil",
"pyjwt",
"defusedxml",
"aiohttp>=3",
]

[dependency-groups]
Expand Down
5 changes: 4 additions & 1 deletion src/ahc/apps/animals/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
8 changes: 8 additions & 0 deletions src/ahc/apps/animals/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
65 changes: 60 additions & 5 deletions src/ahc/apps/animals/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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()
Expand All @@ -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()
Expand Down
29 changes: 29 additions & 0 deletions src/ahc/apps/animals/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ <h3>All pets:</h3>
<h4>Operations:</h4>
<div class="grid">
<a role="button" class="secondary outline" href="{% url 'animal_create' %}">Add animal</a>
<a role="button" class="secondary outline" href="{% url 'animals_archive' %}">In memoriam archive</a>
</div>

{% endif %}
Expand Down
29 changes: 29 additions & 0 deletions src/ahc/apps/animals/templates/animals/archive.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{% extends 'homepage/base.html' %}
{% load static %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/stable_grid.css' %}">
{% endblock %}
{% block content %}

{% if user.is_authenticated %}

<div>
<h3>In memoriam</h3>
{% if animals %}
<div class="stable_grid">
{% for animal in animals %}
{% include "partials/animal_card.html" %}
{% endfor %}
</div>
{% else %}
<p>No archived animals.</p>
{% endif %}
</div>

<div class="grid">
<a role="button" class="secondary outline" href="{% url 'animals_stable' %}">Back to all pets</a>
</div>

{% endif %}

{% endblock %}
16 changes: 16 additions & 0 deletions src/ahc/apps/animals/templates/animals/change_deceased.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{% extends "homepage/base.html" %}
{% load static %}

{% block content %}
<div class="content-section">
<h2>Archive animal as deceased</h2>
<p>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.</p>
<form method="post">
{% csrf_token %}
{% include "partials/form_fields.html" %}
<button type="submit">Archive animal</button>
</form>
<br>
<a role="button" class="secondary outline" href="{% url 'animal_tab' pk=animal_id slug='settings' %}">Back to Settings</a>
</div>
{% endblock %}
15 changes: 15 additions & 0 deletions src/ahc/apps/animals/templates/animals/change_memorial_note.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% extends "homepage/base.html" %}
{% load static %}

{% block content %}
<div class="content-section">
<h2>Edit memorial note</h2>
<form method="post">
{% csrf_token %}
{% include "partials/form_fields.html" %}
<button type="submit">Save</button>
</form>
<br>
<a role="button" class="secondary outline" href="{% url 'animal_profile' pk=animal_id %}">Back to profile</a>
</div>
{% endblock %}
25 changes: 25 additions & 0 deletions src/ahc/apps/animals/templates/animals/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,37 @@
{% block content %}
<div class="content-section">

{% if is_deceased %}
<article class="deceased-banner">
<hgroup>
<h4>In memoriam — {{ animal.date_of_death }}</h4>
{% if animal.memorial_note %}<p>{{ animal.memorial_note }}</p>{% endif %}
</hgroup>
{% if is_owner %}
<div class="grid">
<a role="button" class="secondary outline" href="{% url 'animal_memorial' pk=animal.id %}">Edit memorial note</a>
<form method="post" action="{% url 'animal_unarchive' pk=animal.id %}">
{% csrf_token %}
<button type="submit" class="secondary">Restore (un-archive)</button>
</form>
</div>
{% endif %}
</article>
{% endif %}

<div class="animal-profile-hero">
{% if is_deceased %}
<div class="animal-profile-hero__img">
<img src="{{ animal.profile_image.url }}"
alt="Animal's profile picture">
</div>
{% else %}
<a class="animal-profile-hero__img" href="{% url 'upload_image' pk=animal.id %}">
<img src="{{ animal.profile_image.url }}"
alt="Animal's profile picture"
title="Change a picture">
</a>
{% endif %}
<div class="animal-profile-hero__info">
<h2>{{ animal.full_name }}</h2>

Expand Down
5 changes: 4 additions & 1 deletion src/ahc/apps/animals/templates/animals/tabs/_settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ <h5>Records</h5>
<details>
<summary>Danger zone</summary>
<br>
<a role="button" class="secondary outline" href="{% url 'animal_delete' pk=animal.id %}">Remove this animal from the files</a>
<div class="grid">
<a role="button" class="secondary outline" href="{% url 'animal_deceased' pk=animal.id %}">Archive animal as deceased</a>
<a role="button" class="secondary outline" href="{% url 'animal_delete' pk=animal.id %}">Remove this animal from the files</a>
</div>
</details>

</section>
Loading
Loading