From 88ddb5fe249debd0cb1ee9a0d59a8440b50cb693 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 31 May 2026 05:06:23 +0200 Subject: [PATCH 01/15] refactor(frontend): replace crispy-forms with Pico-native rendering and fix functional bugs --- pyproject.toml | 3 - .../templates/animals/all_animals_stable.html | 22 +- .../templates/animals/change_birthday.html | 11 +- .../animals/change_first_contact.html | 19 +- .../templates/animals/change_owner.html | 29 +-- .../animals/templates/animals/create.html | 20 +- .../apps/animals/templates/animals/image.html | 15 +- .../templates/animals/manage_keepers.html | 37 ++-- .../animals/templates/animals/profile.html | 166 ++++++--------- .../homepage/templates/homepage/base.html | 40 ++-- .../homepage/templates/homepage/homepage.html | 10 +- .../medical_notes/forms/type_basic_note.py | 6 - .../templates/medical_notes/create.html | 34 ++- .../medical_notes/create_notify.html | 34 ++- .../medical_notes/delete_confirm.html | 32 +-- .../templates/medical_notes/edit.html | 26 +-- .../medical_notes/feeding_notes_list.html | 111 ++++------ .../medical_notes/full_timeline_of_notes.html | 197 ++++++++---------- .../medical_notes/notification_list.html | 160 ++++++-------- .../templatetags/custom_file_name.py | 12 +- .../templatetags/custom_to_class_name.py | 12 +- src/ahc/apps/users/templates/users/login.html | 29 ++- .../users/templates/users/login_success.html | 23 +- .../apps/users/templates/users/logout.html | 12 +- .../users/templates/users/password_reset.html | 34 ++- .../users/password_reset_confirm.html | 31 +-- .../apps/users/templates/users/profile.html | 23 +- .../apps/users/templates/users/register.html | 20 +- src/ahc/settings.py | 7 - static/AHC_app/base.css | 0 static/css/custom_pico.css | 16 ++ static/css/expanding_sections.css | 4 - static/css/stable_grid.css | 3 +- static/css/timeline.css | 117 +++-------- static/js/expanding_sections.js | 20 +- static/js/hiding_note_fields_in_form.js | 72 +++---- .../hiding_note_fields_in_measurement_form.js | 54 +++-- static/js/pin_animal.js | 51 +++-- templates/partials/form_fields.html | 15 ++ uv.lock | 43 ---- 40 files changed, 658 insertions(+), 912 deletions(-) delete mode 100644 static/AHC_app/base.css create mode 100644 templates/partials/form_fields.html diff --git a/pyproject.toml b/pyproject.toml index 248bda0..2ebc62d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,9 +16,6 @@ dependencies = [ "pillow>=11.0", "cryptography>=43", "cffi>=1.17", - "django-crispy-forms", - "crispy-bootstrap4", - "django-bootstrap-modal-forms", "django-taggit", "django-timezone-field>=6.1", "tzdata", diff --git a/src/ahc/apps/animals/templates/animals/all_animals_stable.html b/src/ahc/apps/animals/templates/animals/all_animals_stable.html index 3e85e65..ef61539 100644 --- a/src/ahc/apps/animals/templates/animals/all_animals_stable.html +++ b/src/ahc/apps/animals/templates/animals/all_animals_stable.html @@ -1,32 +1,28 @@ {% extends 'homepage/base.html' %} {% load static %} - - +{% block extra_css %} + +{% endblock %} {% block content %} -

Placeholder title

-

Placeholder paragraph

{% if user.is_authenticated %} -
+
{% 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:

- - Add animal
+ {% endif %} {% 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 %}
{% csrf_token %} - {{ form.birthdate }} +
+ {{ form.birthdate.label_tag }} + {{ form.birthdate }} + {% for error in form.birthdate.errors %} + {{ error }} + {% endfor %} +
-

Return to profile

{% 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:

{% csrf_token %} - {{ form.first_contact_vet|as_crispy_field }} - {{ form.first_contact_medical_place|as_crispy_field }} +
+ {{ form.first_contact_vet.label_tag }} + {{ form.first_contact_vet }} + {% for error in form.first_contact_vet.errors %} + {{ error }} + {% endfor %} +
+
+ {{ form.first_contact_medical_place.label_tag }} + {{ form.first_contact_medical_place }} + {% for error in form.first_contact_medical_place.errors %} + {{ error }} + {% endfor %} +
-

Return to profile

{% 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 %}
{% csrf_token %} -
- Set a new keeper for {{ full_name }}: - {{ form.new_owner|as_crispy_field }} +
+ Set a new owner for {{ full_name }}: +
+ {{ form.new_owner.label_tag }} + {{ form.new_owner }} + {% for error in form.new_owner.errors %} + {{ error }} + {% endfor %} +
- - + +
-
- - Return to Pet profile or Stable - -
-
+
+ + 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 %}
{% csrf_token %} -
- Register a new pet! - {{ form|crispy }} +
+ Register a new pet! + {% include "partials/form_fields.html" %}
-
- -
+ -
- - Already registered? Manage them all! - -
+
+ + Already registered? Manage them all! +
{% 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 %} -
- {% csrf_token %} - {{ form.as_p }} - -
- -

Return to profile

+
+ {% csrf_token %} + {% include "partials/form_fields.html" %} + +
+

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..d702ca9 100644 --- a/src/ahc/apps/animals/templates/animals/manage_keepers.html +++ b/src/ahc/apps/animals/templates/animals/manage_keepers.html @@ -1,34 +1,33 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} - {% block content %}
{% csrf_token %} -
- Set a new keeper for {{ full_name }}: - {{ form.input_user|as_crispy_field }} - +
+ Set a new keeper for {{ full_name }}: +
+ {{ form.input_user.label_tag }} + {{ form.input_user }} + {% for error in form.input_user.errors %} + {{ error }} + {% endfor %} +
+
-
- {% if allowed_users %} - -
+ {% if allowed_users %} +

Current Keepers:

-
    +
      {% for user in allowed_users %}
    • {{ user }}
    • {% endfor %}
    - {% endif %} -
-
- - Return to Pet profile or Stable -
-
+ {% 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..7002d61 100644 --- a/src/ahc/apps/animals/templates/animals/profile.html +++ b/src/ahc/apps/animals/templates/animals/profile.html @@ -1,146 +1,118 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} {% load static %} {% load custom_timesince %} +{% block extra_css %} + + +{% endblock %} +{% block extra_js %} + + + +{% endblock %} {% block content %}
-
+
+ + Animal's profile picture + +
+

{{ animal.full_name }}

-
-
- - - -
- + {% if animal.birthdate %} +

Age: {{ animal.birthdate|years_and_months_since:now }}

+

Next birthday: {{ animal.birthdate|date:"d-m" }}-{{ now|date:"Y"}}

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

Owner: {{ animal.owner }}

+ {% if animal.short_description %} +

{{ animal.short_description }}

+ {% endif %}
-
- - -
+ +
-

Expand: additional description

-
-

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor - incididunt ut labore et dolore magna aliqua.

+

Expand: additional description

+
+ {{ animal.long_description|default:"No additional description." }}
-

Expand: first contact details

-
-

{{ animal.first_contact_vet }}

-

{{ animal.first_contact_medical_place }}

+

Expand: first contact details

+
+
{{ animal.first_contact_vet }}
+
{{ animal.first_contact_medical_place }}
-
+

Last records:

-
- - - -
- -
+
+ +
    + {% for record in recent_records reversed %} +
  1. - Plan a new visit - Add a new note - View the full timeline + + {{ record.short_description }}
    -
+ + {% endfor %} +
  • + +
    -
      - {% for record in recent_records reversed %} -
    1. -
      - - {{ record.short_description }} -
      -
    2. - {% endfor %} -
    3. -
    - -

    -
    + + {% if is_owner %} - - {% endblock %} diff --git a/src/ahc/apps/homepage/templates/homepage/base.html b/src/ahc/apps/homepage/templates/homepage/base.html index 04838f5..1bffd91 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,7 +5,11 @@ - AHC + {% if title %} + {{ title }} — AHC + {% else %} + AHC app + {% endif %} @@ -15,29 +18,29 @@ - - {% if title %} - {{ title }} - {% else %} - AHC app - {% endif %} - + {% block extra_css %}{% endblock %} -
    -
    -
    +
    +
    + -
    - Background lizard +
    + Background lizard
    -
    - {% block content %}{% endblock %} +
    + {% if messages %} + {% for message in messages %} +
    {{ message }}
    + {% endfor %} + {% endif %} + + {% block content %}{% endblock %}
    @@ -45,13 +48,13 @@
    +{% block extra_js %}{% endblock %} diff --git a/src/ahc/apps/homepage/templates/homepage/homepage.html b/src/ahc/apps/homepage/templates/homepage/homepage.html index 883c394..bdb577d 100644 --- a/src/ahc/apps/homepage/templates/homepage/homepage.html +++ b/src/ahc/apps/homepage/templates/homepage/homepage.html @@ -4,14 +4,14 @@ {% block content %} -
    +

    Welcome to your pet organizer


    {% if user.is_authenticated %} -
    +

    Operations:

    @@ -22,12 +22,12 @@

    Operations:

    {% if pinned_animals %} -
    +

    Pinned up:

    - {% for animal in recent_animals %} + {% for animal in pinned_animals %} {% include "partials/animal_card.html" %} {% endfor %}
    @@ -37,7 +37,7 @@

    Pinned up:

    {% endif %} {% if recent_animals %} -
    +

    Recent added:

    diff --git a/src/ahc/apps/medical_notes/forms/type_basic_note.py b/src/ahc/apps/medical_notes/forms/type_basic_note.py index 766d818..d7133db 100644 --- a/src/ahc/apps/medical_notes/forms/type_basic_note.py +++ b/src/ahc/apps/medical_notes/forms/type_basic_note.py @@ -1,11 +1,7 @@ from django import forms -# from animals.models import Animal as AnimalProfile from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord, MedicalRecordAttachment -# from django.core.validators import MaxLengthValidator, MinLengthValidator -# from django.db.models import Q - class MedicalRecordForm(forms.ModelForm): TYPES_OF_EVENTS = ( @@ -102,7 +98,6 @@ def __init__(self, *args, **kwargs): def clean(self): cleaned_data = super().clean() - print(cleaned_data) animal = cleaned_data.get("animal") additional_animals = cleaned_data.get("additional_animals") @@ -126,7 +121,6 @@ def clean(self): cleaned_data = super().clean() file = self.cleaned_data.get("file") medical_record_id = self.cleaned_data.get("medical_record_id") - print(f"{medical_record_id=}") if file and file.size > self.MAX_FILE_SIZE: raise forms.ValidationError("Files of size above 15MB are not allowed") diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/create.html b/src/ahc/apps/medical_notes/templates/medical_notes/create.html index 90290bd..c1a8883 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/create.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/create.html @@ -1,29 +1,25 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} {% load static %} +{% block extra_js %} + {% if form_name == 'MedicalRecordForm' %} + + {% elif form_name == 'BiometricRecordForm' %} + + {% endif %} +{% endblock %} {% block content %}
    - {% if form_name == 'MedicalRecordForm' %} - - {% elif form_name == 'BiometricRecordForm' %} - - {% endif %} {% csrf_token %} -
    - Register a new note related with: -
    - {{ form|crispy }} +
    + Register a new note related with: + {% include "partials/form_fields.html" %}
    -
    - -
    + - +
    + + Return to the pet profile +
    {% endblock %} diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/create_notify.html b/src/ahc/apps/medical_notes/templates/medical_notes/create_notify.html index 27f7bf6..c1a8883 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/create_notify.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/create_notify.html @@ -1,29 +1,25 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} {% load static %} +{% block extra_js %} + {% if form_name == 'MedicalRecordForm' %} + + {% elif form_name == 'BiometricRecordForm' %} + + {% endif %} +{% endblock %} {% block content %}
    - {% if form_name == 'MedicalRecordForm' %} - - {% elif form_name == 'BiometricRecordForm' %} - - {% endif %} {% csrf_token %} -
    - Register a new note related with: -
    - {{ form|crispy }} +
    + Register a new note related with: + {% include "partials/form_fields.html" %}
    -
    - -
    + -
    -{# #} -{# Return to the pet#} -{# profile#} -{# #} -
    +
    + + Return to the pet profile +
    {% endblock %} diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/delete_confirm.html b/src/ahc/apps/medical_notes/templates/medical_notes/delete_confirm.html index 6fdc5c4..c14958f 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/delete_confirm.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/delete_confirm.html @@ -1,26 +1,14 @@ {% extends "homepage/base.html" %} -{%block content%} - -
    -
    - {% csrf_token %} -

    Are you sure?

    -
    -

    {{ note.short_description }}

    -
    - {% for field in form %} - {{ field.label_tag }} - {{ field }} - {% if field.errors %} - {{ field.errors|striptags }} - {% endif %} - {% endfor %} - - -
    - -
    -
    +{% block content %} + +
    +
    + {% csrf_token %} +

    Are you sure?

    +

    {{ note.short_description }}

    + {% include "partials/form_fields.html" %} + +
    {% endblock %} diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/edit.html b/src/ahc/apps/medical_notes/templates/medical_notes/edit.html index 1491f5f..3a4ae6b 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/edit.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/edit.html @@ -1,25 +1,21 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} {% load static %} +{% block extra_js %} + +{% endblock %} {% block content %}
    - {% csrf_token %} -
    - Edit a note related with {{ note.animal.full_name }}: -
    - {{ form|crispy }} +
    + Edit a note related with {{ note.animal.full_name }}: + {% include "partials/form_fields.html" %}
    -
    - -
    + - +
    + + Return to the stable +
    {% endblock %} diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/feeding_notes_list.html b/src/ahc/apps/medical_notes/templates/medical_notes/feeding_notes_list.html index 4d77328..9a2e2da 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/feeding_notes_list.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/feeding_notes_list.html @@ -1,78 +1,57 @@ {% extends "homepage/base.html" %} {% block content %} -
    -

    Feeding Notes

    -
    -
    -
    - {% for note in feeding_notes %} -
    -

    {{ note.product_name }}

    -

    Category: {{ note.category }}

    -

    Producer: {{ note.producer }}

    -

    Dose Annotations: {{ note.dose_annotations }}

    -

    Real Start Date: {{ note.real_start_date }}

    -

    Real End Date: {{ note.real_end_date }}

    -

    Is Medicine: {{ note.is_medicine }}

    -

    Have Active Notifications: {{ note.related_note.is_active|yesno:"Yes,No,None" }}

    - +
    +

    Feeding Notes

    + {% for note in feeding_notes %} +
    +

    {{ note.product_name }}

    +

    Category: {{ note.category }}

    +

    Producer: {{ note.producer }}

    +

    Dose annotations: {{ note.dose_annotations }}

    +

    Real start date: {{ note.real_start_date }}

    +

    Real end date: {{ note.real_end_date }}

    +

    Is medicine: {{ note.is_medicine }}

    +

    Has active notifications: {{ note.related_note.is_active|yesno:"Yes,No,None" }}

    + - {% empty %} -

    No feeding notes found for this MedicalRecord.

    - {% endfor %} -
    + + {% empty %} +

    No feeding notes found for this record.

    + {% endfor %} + + -
    -
    - {% if notes.paginator.page_range|length > 1 %} -
    - - Pages: - - - {% if notes.has_previous %} - First - Previous - {% endif %} - - - {% for num in notes.paginator.page_range %} - {% if notes.number == num %} - {{ num }} - {% elif num > notes.number|add:'-3' and num < notes.number|add:'3' %} - {{ num }} - {% endif %} - {% endfor %} + {% endif %} -
    - {% endif %} -
    {% endblock %} diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/full_timeline_of_notes.html b/src/ahc/apps/medical_notes/templates/medical_notes/full_timeline_of_notes.html index 00369af..5a1ad8e 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/full_timeline_of_notes.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/full_timeline_of_notes.html @@ -1,143 +1,122 @@ {% extends "homepage/base.html" %} {% load static %} +{% block extra_css %} + +{% endblock %} {% block content %} {% for note, form in notes %} -
    -
    - {{ note.animal.full_name }} - +
    +
    + {{ note.animal.full_name }}
    -

    {{ note.short_description }}

    - + {{ note.date_creation }}
    -
    -

    Appendixes:

    - {% for attachment in note.attachments.all %} -
    -
    -

    Uploaded: {{ attachment.upload_date }}

    -
    - -
    - - {% if not attachment.description %} -

    No description

    - {% else %} -

    {{ attachment.description }}

    - {% endif %} -
    - - -
    + + Type: + {{ note.type_of_event }} + +
    - {% endfor %} -
    - {% csrf_token %} - {{ form.as_p }} - -
    - {% if messages %} -
      - {% for message in messages %} - {{ message }} - {% endfor %} -
    - {% endif %} -
    - -
    +

    {{ note.short_description }}

    +
    +

    Appendixes:

    + {% for attachment in note.attachments.all %} +
    +
    +

    Uploaded: {{ attachment.upload_date }}

    +
    + +
    + {% if attachment.description %} +

    {{ attachment.description }}

    + {% else %} +

    No description

    + {% endif %} +
    +
    + Edit +
    +
    + Delete +
    +
    + {% endfor %} + +
    + {% csrf_token %} + {% include "partials/form_fields.html" %} + +
    +
    - {% if note.additional_animals.all|length != 0 %} -

    Related also to: - {% for animal in note.additional_animals.all %} - {{ animal.full_name }}{% if not forloop.last %},{% endif %} - {% endfor %} -

    - {% endif %} + {% if note.additional_animals.all %} +

    Related also to: + {% for animal in note.additional_animals.all %} + {{ animal.full_name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +

    + {% endif %} - {% if note.note_tags.all %} + {% if note.note_tags.all %}

    Tags: {% for tag in note.note_tags.all %} - #{{ tag.name }}{% if not forloop.last %}, {% endif %} {% endfor %} + #{{ tag.name }}{% if not forloop.last %}, {% endif %} + {% endfor %}

    - {% endif %} + {% endif %} + +
    + {% endfor %}
    - -
    - - Return to the pet - profile + {% if paginator.page_range|length > 1 %} -
    - - Pages: - - - {% if page_obj.has_previous %} - First - Previous - {% endif %} +
    + {% if page_obj.has_next %} + Next + Last + {% endif %} + {% endif %} -
    {% endblock %} diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/notification_list.html b/src/ahc/apps/medical_notes/templates/medical_notes/notification_list.html index 78ddcff..19f0adf 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/notification_list.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/notification_list.html @@ -1,115 +1,79 @@ {% extends "homepage/base.html" %} -{% load custom_file_name %} +{% load custom_to_class_name %} {% block content %} - -
    -

    A notes related to PLACEHOLDER_FOR_MEDNOTE/FEEDNOTE/ANIMAL

    -
    +
    +

    Notifications for this record

    {% for note in notifications %} -
    -
    - -
    -
    -
    -
    - {% csrf_token %} - -
    - {#
    #} +
    + Latest changed: {{ note.last_modification }} +
    + {% csrf_token %} - - {# Set inactive#} - {# Delete#} -
    -
    + +
    + {% csrf_token %} + +
    +
    + + {% endfor %} {% endblock %} diff --git a/src/ahc/apps/medical_notes/templatetags/custom_file_name.py b/src/ahc/apps/medical_notes/templatetags/custom_file_name.py index 79421c0..1e63738 100644 --- a/src/ahc/apps/medical_notes/templatetags/custom_file_name.py +++ b/src/ahc/apps/medical_notes/templatetags/custom_file_name.py @@ -1,16 +1,14 @@ from django import template -from ahc.apps.medical_notes.models.type_feeding_notes import FeedingNotification - register = template.Library() @register.filter -def to_class_name(value): +def to_file_name(value): if value is None: - raise template.TemplateSyntaxError("Value cannot be None") + return "" - if FeedingNotification not in value.__class__.__bases__: - raise template.TemplateSyntaxError("Not allowed to use on the model") + if not isinstance(value, str): + return value - return value.__class__.__name__ + return value.split("/")[-1] diff --git a/src/ahc/apps/medical_notes/templatetags/custom_to_class_name.py b/src/ahc/apps/medical_notes/templatetags/custom_to_class_name.py index ba3059c..f1b4dfb 100644 --- a/src/ahc/apps/medical_notes/templatetags/custom_to_class_name.py +++ b/src/ahc/apps/medical_notes/templatetags/custom_to_class_name.py @@ -1,14 +1,16 @@ from django import template +from ahc.apps.medical_notes.models.type_feeding_notes import FeedingNotification + register = template.Library() @register.filter -def to_file_name(value): +def to_class_name(value): if value is None: - raise template.TemplateSyntaxError("Value cannot be None") + return "" - if not isinstance(value, str): - return value + if not isinstance(value, FeedingNotification): + return "" - return value.split("/")[-1] + return value.__class__.__name__ diff --git a/src/ahc/apps/users/templates/users/login.html b/src/ahc/apps/users/templates/users/login.html index 2322254..713c689 100644 --- a/src/ahc/apps/users/templates/users/login.html +++ b/src/ahc/apps/users/templates/users/login.html @@ -1,26 +1,21 @@ {% extends "homepage/base.html" %} -{% load static %} -{% load crispy_forms_tags %} {% block content %}
    {% csrf_token %} -
    - Log In! - {{ form|crispy }} +
    + Log In! + {% include "partials/form_fields.html" %}
    -
    - -
    + -
    - - Forgot your password? Reset Password - -
    - - Does not have an account? Sing In - -
    +
    + + Forgot your password? Reset Password + +
    + + Don't have an account? Sign Up +
    {% endblock %} diff --git a/src/ahc/apps/users/templates/users/login_success.html b/src/ahc/apps/users/templates/users/login_success.html index 106bfab..a67a0b3 100644 --- a/src/ahc/apps/users/templates/users/login_success.html +++ b/src/ahc/apps/users/templates/users/login_success.html @@ -1,24 +1,21 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} {% block content %}
    -
    - -
    - -

    {{ user.email }}

    +
    + User's profile picture +
    +

    {{ user.username }}

    +

    {{ user.email }}

    {% csrf_token %} -
    - Profile info! - {{ user_form|crispy }} - {{ profile_update }} +
    + Profile info + {% include "partials/form_fields.html" with form=user_form %} + {% include "partials/form_fields.html" with form=profile_update %}
    -
    - -
    +
    {% endblock %} diff --git a/src/ahc/apps/users/templates/users/logout.html b/src/ahc/apps/users/templates/users/logout.html index 28c0a16..be1ef65 100644 --- a/src/ahc/apps/users/templates/users/logout.html +++ b/src/ahc/apps/users/templates/users/logout.html @@ -1,12 +1,10 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} {% block content %}
    -

    You have been logout

    -
    - - Do you want to log in again? Sing In - -
    +

    You have been logged out

    +
    + + Do you want to log in again? Sign In +
    {% endblock %} diff --git a/src/ahc/apps/users/templates/users/password_reset.html b/src/ahc/apps/users/templates/users/password_reset.html index 02384ea..b1d22c1 100644 --- a/src/ahc/apps/users/templates/users/password_reset.html +++ b/src/ahc/apps/users/templates/users/password_reset.html @@ -1,20 +1,16 @@ {% extends "homepage/base.html" %} -{%block content%} -
    -
    - {% csrf_token %} -

    Reset Password

    - {% for field in form %} - {{ field.label_tag }} - {{ field }} - {% if field.errors %} - {{ field.errors|striptags }} - {% endif %} - {% endfor %} -
    - - Cancel -
    -
    -
    -{%endblock content%} +{% block content %} +
    +
    + {% csrf_token %} +
    + Reset Password + {% include "partials/form_fields.html" %} +
    +
    + + Cancel +
    +
    +
    +{% endblock %} diff --git a/src/ahc/apps/users/templates/users/password_reset_confirm.html b/src/ahc/apps/users/templates/users/password_reset_confirm.html index 5e3d36c..9bcfa6d 100644 --- a/src/ahc/apps/users/templates/users/password_reset_confirm.html +++ b/src/ahc/apps/users/templates/users/password_reset_confirm.html @@ -1,24 +1,13 @@ {% extends "homepage/base.html" %} -{%block content%} - -
    -
    - {% csrf_token %} -

    Password Reset Confirm

    - - {% for field in form %} - {{ field.label_tag }} - {{ field }} - {% if field.errors %} - {{ field.errors|striptags }} - {% endif %} - {% endfor %} - - -
    - -
    -
    +{% block content %} +
    +
    + {% csrf_token %} +
    + Password Reset Confirm + {% include "partials/form_fields.html" %} +
    + +
    - {% endblock %} diff --git a/src/ahc/apps/users/templates/users/profile.html b/src/ahc/apps/users/templates/users/profile.html index df44e9f..a67a0b3 100644 --- a/src/ahc/apps/users/templates/users/profile.html +++ b/src/ahc/apps/users/templates/users/profile.html @@ -1,24 +1,21 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} {% block content %}
    -
    - -
    - -

    {{ user.email }}

    +
    + User's profile picture +
    +

    {{ user.username }}

    +

    {{ user.email }}

    {% csrf_token %} -
    - Profile info! - {{ user_form|crispy }} - {{ profile_update }} +
    + Profile info + {% include "partials/form_fields.html" with form=user_form %} + {% include "partials/form_fields.html" with form=profile_update %}
    -
    - -
    +
    {% endblock %} diff --git a/src/ahc/apps/users/templates/users/register.html b/src/ahc/apps/users/templates/users/register.html index a8b7b34..baa5c44 100644 --- a/src/ahc/apps/users/templates/users/register.html +++ b/src/ahc/apps/users/templates/users/register.html @@ -1,21 +1,17 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} {% block content %}
    {% csrf_token %} -
    - Join! - {{ form|crispy }} +
    + Join! + {% include "partials/form_fields.html" %}
    -
    - -
    + -
    - - Have an account? Sing In - -
    +
    + + Have an account? Sign In +
    {% endblock %} diff --git a/src/ahc/settings.py b/src/ahc/settings.py index ab6ef02..7a26412 100644 --- a/src/ahc/settings.py +++ b/src/ahc/settings.py @@ -67,9 +67,6 @@ def _skip_external_services() -> bool: "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.postgres", - "crispy_forms", - "crispy_bootstrap4", - "bootstrap_modal_forms", "taggit", "ahc.apps.homepage.apps.HomepageConfig", "ahc.apps.users.apps.UsersConfig", @@ -108,10 +105,6 @@ def _skip_external_services() -> bool: ] -CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap4" -CRISPY_TEMPLATE_PACK = "bootstrap4" - - WSGI_APPLICATION = "ahc.wsgi.application" diff --git a/static/AHC_app/base.css b/static/AHC_app/base.css deleted file mode 100644 index e69de29..0000000 diff --git a/static/css/custom_pico.css b/static/css/custom_pico.css index 6220a70..466cf35 100644 --- a/static/css/custom_pico.css +++ b/static/css/custom_pico.css @@ -13,3 +13,19 @@ --pico-secondary-underline: rgba(187, 187, 187, 0.5); --pico-secondary-inverse: #000; } + +/* Animal card: background image overlay button */ +.btn-with-bg { + background-size: cover; + background-position: center; + min-height: 8rem; + display: flex; + align-items: flex-end; + padding: 0.5rem; +} + +/* Form validation error hint */ +.form-error { + color: var(--pico-color-red-500, #e53e3e); + display: block; +} diff --git a/static/css/expanding_sections.css b/static/css/expanding_sections.css index 9205ed5..16fac9c 100644 --- a/static/css/expanding_sections.css +++ b/static/css/expanding_sections.css @@ -1,7 +1,3 @@ -.section { - -} - .section-header { cursor: pointer; } diff --git a/static/css/stable_grid.css b/static/css/stable_grid.css index 3bff4fa..4582e42 100644 --- a/static/css/stable_grid.css +++ b/static/css/stable_grid.css @@ -1,7 +1,6 @@ -/* styles.css */ .stable_grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-gap: 20px; } diff --git a/static/css/timeline.css b/static/css/timeline.css index f15adda..8b88631 100644 --- a/static/css/timeline.css +++ b/static/css/timeline.css @@ -1,55 +1,28 @@ -@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"); +/* Timeline component styles. + * + * Note: Inter font previously imported via Google Fonts CDN (render-blocking). + * Falls back to system-ui until the font is vendored locally. + */ :root { - --white: #fff; - --black: #323135; - --crystal: #a8dadd; - --columbia-blue: #cee9e4; - --midnight-green: #01565b; - --yellow: #e5f33d; - --timeline-gradient: rgba(206, 233, 228, 1) 0%, rgba(206, 233, 228, 1) 50%, - rgba(206, 233, 228, 0) 100%; - -} - -*, -*::before, -*::after { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -button { - background: transparent; - border: none; - cursor: pointer; - outline: none; -} - -a { - color: inherit; -} - -img { - max-width: 100%; - height: auto; -} - -body { - + --timeline-white: #fff; + --timeline-black: #323135; + --timeline-crystal: #a8dadd; + --timeline-columbia-blue: #cee9e4; + --timeline-midnight-green: #01565b; + --timeline-yellow: #e5f33d; } -/* .section SECTION +/* SECTION –––––––––––––––––––––––––––––––––––––––––––––––––– */ .section { padding: 50px 0; - font: normal 16px/1.5 "Inter", sans-serif; - color: var(--black); + font: normal 16px/1.5 system-ui, sans-serif; + color: var(--timeline-black); } -.section .container { +.section > .container { width: 90%; max-width: 1200px; margin: 0 auto; @@ -75,37 +48,23 @@ body { padding: 0 10px; margin: 0 auto; display: grid; - grid-template-columns: 320px auto; + grid-template-columns: minmax(200px, 320px) auto; grid-gap: 20px; - } -.timeline::before, -.timeline::after { - content: ""; - position: absolute; - top: 0; - bottom: 30px; - width: 100px; - z-index: 2; -} -/* -.timeline::after { - right: 0; - background: linear-gradient(270deg, var(--timeline-gradient)); +@media (max-width: 640px) { + .timeline { + grid-template-columns: 1fr; + white-space: normal; + } } -.timeline::before { - left: 340px; - background: linear-gradient(90deg, var(--timeline-gradient)); -} -*/ .timeline .info { display: flex; flex-direction: column; justify-content: center; padding: 20px 40px; - color: var(--white); + color: var(--timeline-white); white-space: normal; border-radius: 10px; } @@ -116,17 +75,7 @@ body { .timeline .info div { margin-top: 10px; - color: var(--crystal); - -} - - -.timeline .info a { - display: block; - width: fit-content; - margin: 40px auto; - text-decoration: none; - background: black; + color: var(--timeline-crystal); } .timeline ol::-webkit-scrollbar { @@ -139,11 +88,11 @@ body { } .timeline ol::-webkit-scrollbar-thumb { - background: var(--midnight-green); + background: var(--timeline-midnight-green); } .timeline ol::-webkit-scrollbar-track { - background: var(--yellow); + background: var(--timeline-yellow); } .timeline ol { @@ -152,7 +101,7 @@ body { transition: all 1s; overflow-x: scroll; scroll-snap-type: x mandatory; - scrollbar-color: var(--yellow) var(--midnight-green); + scrollbar-color: var(--timeline-yellow) var(--timeline-midnight-green); } .timeline ol li { @@ -161,7 +110,7 @@ body { list-style-type: none; width: 160px; height: 5px; - background: var(--white); + background: var(--timeline-white); scroll-snap-align: start; } @@ -183,7 +132,7 @@ body { height: 16px; transform: translateY(-50%); border-radius: 50%; - background: var(--midnight-green); + background: var(--timeline-midnight-green); z-index: 1; } @@ -194,8 +143,8 @@ body { padding: 15px; font-size: 1rem; white-space: normal; - color: var(--black); - background: var(--white); + color: var(--timeline-black); + background: var(--timeline-white); border-radius: 0 10px 10px 10px; } @@ -218,7 +167,7 @@ body { .timeline ol li:nth-child(odd) div::before { top: 100%; border-width: 8px 8px 0 0; - border-color: var(--white) transparent transparent transparent; + border-color: var(--timeline-white) transparent transparent transparent; } .timeline ol li:nth-child(even) div { @@ -228,7 +177,7 @@ body { .timeline ol li:nth-child(even) div::before { top: -8px; border-width: 8px 0 0 8px; - border-color: transparent transparent transparent var(--white); + border-color: transparent transparent transparent var(--timeline-white); } .timeline time { @@ -236,5 +185,5 @@ body { font-size: 1.4rem; font-weight: bold; margin-bottom: 8px; - color: var(--midnight-green); + color: var(--timeline-midnight-green); } diff --git a/static/js/expanding_sections.js b/static/js/expanding_sections.js index 803da26..09d518b 100644 --- a/static/js/expanding_sections.js +++ b/static/js/expanding_sections.js @@ -5,11 +5,21 @@ document.addEventListener("DOMContentLoaded", function() { const header = section.querySelector(".section-header"); const content = section.querySelector(".section-content"); - header.addEventListener("click", () => { - if (content.style.display === "none" || content.style.display === "") { - content.style.display = "block"; - } else { - content.style.display = "none"; + if (!header || !content) return; + + header.setAttribute("aria-expanded", "false"); + + const toggle = () => { + const expanded = content.style.display !== "none" && content.style.display !== ""; + content.style.display = expanded ? "none" : "block"; + header.setAttribute("aria-expanded", String(!expanded)); + }; + + header.addEventListener("click", toggle); + header.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggle(); } }); }); diff --git a/static/js/hiding_note_fields_in_form.js b/static/js/hiding_note_fields_in_form.js index 59e0d7e..7648824 100644 --- a/static/js/hiding_note_fields_in_form.js +++ b/static/js/hiding_note_fields_in_form.js @@ -1,53 +1,35 @@ document.addEventListener('DOMContentLoaded', function() { - function handleTypeOfEventChange() { - const typeOfEventField = document.getElementById('id_type_of_event'); - const participantsField = document.getElementById('div_id_participants'); - const placeField = document.getElementById('div_id_place'); - const fulldescriptionField = document.getElementById('div_id_full_description'); - const eventstartedField = document.getElementById('div_id_date_event_started'); - const eventendedField = document.getElementById('div_id_date_event_ended'); + const typeOfEventField = document.getElementById('id_type_of_event'); + if (!typeOfEventField) return; + + const participantsField = document.getElementById('field_participants'); + const placeField = document.getElementById('field_place'); + const fulldescriptionField = document.getElementById('field_full_description'); + const eventstartedField = document.getElementById('field_date_event_started'); + const eventendedField = document.getElementById('field_date_event_ended'); - if (typeOfEventField.value === 'fast_note') { - participantsField.style.display = 'none'; - placeField.style.display = 'none'; - fulldescriptionField.style.display = 'none'; - eventstartedField.style.display = 'none'; - eventendedField.style.display = 'none'; - } else if (typeOfEventField.value === 'medical_visit') { - participantsField.style.display = 'block'; - placeField.style.display = 'block'; - fulldescriptionField.style.display = 'block'; - eventstartedField.style.display = 'block'; - eventendedField.style.display = 'none'; - } else if (typeOfEventField.value === 'biometric_record') { - participantsField.style.display = 'none'; - placeField.style.display = 'none'; - fulldescriptionField.style.display = 'block'; - eventstartedField.style.display = 'block'; - eventendedField.style.display = 'none'; - } else if (typeOfEventField.value === 'diet_note') { - participantsField.style.display = 'none'; - placeField.style.display = 'none'; - fulldescriptionField.style.display = 'block'; - eventstartedField.style.display = 'block'; - eventendedField.style.display = 'block'; - } else if (typeOfEventField.value === 'medicament_note') { - participantsField.style.display = 'none'; - placeField.style.display = 'none'; - fulldescriptionField.style.display = 'block'; - eventstartedField.style.display = 'block'; - eventendedField.style.display = 'block'; - } else if (typeOfEventField.value === 'other_user_note') { - participantsField.style.display = 'block'; - placeField.style.display = 'block'; - fulldescriptionField.style.display = 'block'; - eventstartedField.style.display = 'block'; - eventendedField.style.display = 'block'; + const allOptional = [participantsField, placeField, fulldescriptionField, eventstartedField, eventendedField]; + + function show(...fields) { + allOptional.forEach(f => { if (f) f.style.display = 'none'; }); + fields.forEach(f => { if (f) f.style.display = 'block'; }); + } + + function handleTypeOfEventChange() { + const value = typeOfEventField.value; + if (value === 'fast_note') { + show(); + } else if (value === 'medical_visit') { + show(participantsField, placeField, fulldescriptionField, eventstartedField); + } else if (value === 'biometric_record') { + show(fulldescriptionField, eventstartedField); + } else if (value === 'diet_note' || value === 'medicament_note') { + show(fulldescriptionField, eventstartedField, eventendedField); + } else if (value === 'other_user_note') { + show(participantsField, placeField, fulldescriptionField, eventstartedField, eventendedField); } } handleTypeOfEventChange(); - - const typeOfEventField = document.getElementById('id_type_of_event'); typeOfEventField.addEventListener('change', handleTypeOfEventChange); }); diff --git a/static/js/hiding_note_fields_in_measurement_form.js b/static/js/hiding_note_fields_in_measurement_form.js index 75d7d7b..c3a2506 100644 --- a/static/js/hiding_note_fields_in_measurement_form.js +++ b/static/js/hiding_note_fields_in_measurement_form.js @@ -1,39 +1,35 @@ document.addEventListener('DOMContentLoaded', function() { - function handleTypeOfEventChange() { - const typeOfEventField = document.getElementById('id_record_type'); - const weightField = document.getElementById('div_id_weight'); - const weightUnitField = document.getElementById('div_id_weight_unit_to_present'); - const heightField = document.getElementById('div_id_height'); - const heightUnitField = document.getElementById('div_id_height_unit_to_present'); - const customNameField = document.getElementById('div_id_custom_name'); - const customValueField = document.getElementById('div_id_custom_value'); - const customUnitField = document.getElementById('div_id_custom_unit'); + const typeOfEventField = document.getElementById('id_record_type'); + if (!typeOfEventField) return; - const selectedRecordType = typeOfEventField.value; + const weightField = document.getElementById('field_weight'); + const weightUnitField = document.getElementById('field_weight_unit_to_present'); + const heightField = document.getElementById('field_height'); + const heightUnitField = document.getElementById('field_height_unit_to_present'); + const customNameField = document.getElementById('field_custom_name'); + const customValueField = document.getElementById('field_custom_value'); + const customUnitField = document.getElementById('field_custom_unit'); - weightField.style.display = 'none'; - weightUnitField.style.display = 'none'; - heightField.style.display = 'none'; - heightUnitField.style.display = 'none'; - customNameField.style.display = 'none'; - customValueField.style.display = 'none'; - customUnitField.style.display = 'none'; + const allOptional = [weightField, weightUnitField, heightField, heightUnitField, customNameField, customValueField, customUnitField]; + + function show(...fields) { + allOptional.forEach(f => { if (f) f.style.display = 'none'; }); + fields.forEach(f => { if (f) f.style.display = 'block'; }); + } - if (selectedRecordType === 'weight') { - weightField.style.display = 'block'; - weightUnitField.style.display = 'block'; - } else if (selectedRecordType === 'height') { - heightField.style.display = 'block'; - heightUnitField.style.display = 'block'; - } else if (selectedRecordType === 'custom') { - customNameField.style.display = 'block'; - customValueField.style.display = 'block'; - customUnitField.style.display = 'block'; + function handleTypeOfEventChange() { + const value = typeOfEventField.value; + if (value === 'weight') { + show(weightField, weightUnitField); + } else if (value === 'height') { + show(heightField, heightUnitField); + } else if (value === 'custom') { + show(customNameField, customValueField, customUnitField); + } else { + show(); } } handleTypeOfEventChange(); - - const typeOfEventField = document.getElementById('id_record_type'); typeOfEventField.addEventListener('change', handleTypeOfEventChange); }); diff --git a/static/js/pin_animal.js b/static/js/pin_animal.js index d0c0baf..9cb68d3 100644 --- a/static/js/pin_animal.js +++ b/static/js/pin_animal.js @@ -1,35 +1,34 @@ const link = document.getElementById('togglePinnedButton'); -link.addEventListener('click', async function(event) { - event.preventDefault(); +if (link) { + link.addEventListener('click', async function(event) { + event.preventDefault(); - const animalId = link.dataset.animalId; - const action = link.dataset.action; - const newAction = (action === 'add') ? 'remove' : 'add'; + const animalId = link.dataset.animalId; + const action = link.dataset.action; + const newAction = (action === 'add') ? 'remove' : 'add'; - try { - const response = await fetch(link.href, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': getCookie('csrftoken'), - }, - body: JSON.stringify({animal_id: animalId, action: action}), - }); + try { + const body = new URLSearchParams({animal_id: animalId, action: action}); + const response = await fetch(link.href, { + method: 'POST', + headers: { + 'X-CSRFToken': getCookie('csrftoken'), + }, + body: body, + }); - if (!response.ok) { - throw new Error('Request failed: ' + response.statusText); - } - - link.dataset.action = newAction; - link.innerText = (newAction === 'add') ? 'Add to Pinned' : 'Remove from Pinned'; + if (!response.ok) { + throw new Error('Request failed: ' + response.statusText); + } - const result = await response.json(); - console.log(result); // Check the result in the console - } catch (error) { - console.error('Error:', error); - } -}); + link.dataset.action = newAction; + link.innerText = (newAction === 'add') ? 'Add to Pinned' : 'Remove from Pinned'; + } catch (error) { + console.error('Error:', error); + } + }); +} function getCookie(name) { let cookieValue = null; diff --git a/templates/partials/form_fields.html b/templates/partials/form_fields.html new file mode 100644 index 0000000..eb40be8 --- /dev/null +++ b/templates/partials/form_fields.html @@ -0,0 +1,15 @@ +{% for field in form %} +
    + {{ field.label_tag }} + {{ field }} + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    +{% endfor %} +{% for field in form.hidden_fields %} + {{ field }} +{% endfor %} diff --git a/uv.lock b/uv.lock index c3d6059..2e54726 100644 --- a/uv.lock +++ b/uv.lock @@ -93,13 +93,10 @@ source = { virtual = "." } dependencies = [ { name = "celery" }, { name = "cffi" }, - { name = "crispy-bootstrap4" }, { name = "cryptography" }, { name = "defusedxml" }, { name = "discord" }, { name = "django" }, - { name = "django-bootstrap-modal-forms" }, - { name = "django-crispy-forms" }, { name = "django-taggit" }, { name = "django-timezone-field" }, { name = "djangorestframework" }, @@ -133,13 +130,10 @@ dev = [ requires-dist = [ { name = "celery", specifier = ">=5.4" }, { name = "cffi", specifier = ">=1.17" }, - { name = "crispy-bootstrap4" }, { name = "cryptography", specifier = ">=43" }, { name = "defusedxml" }, { name = "discord" }, { name = "django", specifier = ">=6.0,<6.1" }, - { name = "django-bootstrap-modal-forms" }, - { name = "django-crispy-forms" }, { name = "django-taggit" }, { name = "django-timezone-field", specifier = ">=6.1" }, { name = "djangorestframework", specifier = ">=3.14" }, @@ -478,19 +472,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, ] -[[package]] -name = "crispy-bootstrap4" -version = "2026.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, - { name = "django-crispy-forms" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/cc/638d36595da9fbb2c9d0be98bf6007442a65a521b614cf4645d04311b061/crispy_bootstrap4-2026.2.tar.gz", hash = "sha256:66f8f14bf9c2c16ed94243236ed253a94e5a625afa1ee64022ce29db98c6cd85", size = 34645, upload-time = "2026-02-11T22:45:05.422Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/6d/b90d601ea2449cc6b35b4b08be90fb1f6ca1baf2be383ed195f7bfa91a32/crispy_bootstrap4-2026.2-py3-none-any.whl", hash = "sha256:4b2b99dfe3e3cacb548702159462110901bd38792b650b770e50c62284ac2227", size = 23178, upload-time = "2026-02-11T22:45:04.108Z" }, -] - [[package]] name = "cryptography" version = "48.0.0" @@ -601,30 +582,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/5b/1328f8b84fce040c404f76822bf8c57d254e368e8cbd8bd67ec2b26d75f5/django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0", size = 8368680, upload-time = "2026-05-05T13:54:33.532Z" }, ] -[[package]] -name = "django-bootstrap-modal-forms" -version = "3.0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4f/92/d1d37315c897e2ad311242d617d87e26f507e0446bdfd6a0ef265501d138/django_bootstrap_modal_forms-3.0.5.tar.gz", hash = "sha256:322930953c68e1dcd4c5dc073612c77ff4d68c46074a55d98db2de6e7860050b", size = 38292, upload-time = "2024-09-28T13:39:55.656Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/e7/84a437e7d413e14627b82d6f3478960d01e88b5e927f673cee536e6907ba/django_bootstrap_modal_forms-3.0.5-py3-none-any.whl", hash = "sha256:e56bbe05fb29c5aa9e0f3c0277b0d8363b81cc6c4e4aaf152cedea883edae58a", size = 29560, upload-time = "2024-09-28T13:39:53.645Z" }, -] - -[[package]] -name = "django-crispy-forms" -version = "2.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/73/42/c2cfb672493730b963ef377b103e29871c56348a215d0ae8cf362fe8ab1e/django_crispy_forms-2.6.tar.gz", hash = "sha256:4921a1087c6cd4f9fa3c139654c1de1c1c385f8bd6729aaee530bc0121ab4b93", size = 1097838, upload-time = "2026-03-01T09:03:37.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/e3/4c5915a732d6ab54da8871400852b67529518eedfb6b78ecf10bbccfcabb/django_crispy_forms-2.6-py3-none-any.whl", hash = "sha256:8ee0ae28b6b0ac41ff48a65944480c049fe8d1b0047086874fd7efabf4ec1374", size = 31479, upload-time = "2026-03-01T09:03:36.048Z" }, -] - [[package]] name = "django-taggit" version = "6.1.0" From 0397ce1918abf69fa26e658bca2df5727ba66754 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 31 May 2026 05:17:11 +0200 Subject: [PATCH 02/15] =?UTF-8?q?refactor(frontend):=20finish=20frontend?= =?UTF-8?q?=20cleanup=20=E2=80=94=20dedup=20templates,=20a11y,=20paginatio?= =?UTF-8?q?n=20partial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../homepage/templates/homepage/homepage.html | 5 ++-- .../medical_notes/create_notify.html | 25 ------------------- .../medical_notes/feeding_notes_list.html | 23 ----------------- .../medical_notes/full_timeline_of_notes.html | 24 +----------------- .../medical_notes/notification_list.html | 4 +-- .../medical_notes/views/type_feeding_notes.py | 2 +- .../users/templates/users/login_success.html | 19 +------------- .../users/password_reset_complete.html | 12 ++++----- .../templates/users/password_reset_done.html | 14 +++++------ .../apps/users/templates/users/profile.html | 19 +------------- static/css/custom_pico.css | 5 ++++ templates/partials/animal_card.html | 9 +++++++ templates/partials/pagination.html | 23 +++++++++++++++++ templates/partials/user_profile_section.html | 18 +++++++++++++ 14 files changed, 73 insertions(+), 129 deletions(-) delete mode 100644 src/ahc/apps/medical_notes/templates/medical_notes/create_notify.html create mode 100644 templates/partials/pagination.html create mode 100644 templates/partials/user_profile_section.html diff --git a/src/ahc/apps/homepage/templates/homepage/homepage.html b/src/ahc/apps/homepage/templates/homepage/homepage.html index bdb577d..e675408 100644 --- a/src/ahc/apps/homepage/templates/homepage/homepage.html +++ b/src/ahc/apps/homepage/templates/homepage/homepage.html @@ -4,9 +4,8 @@ {% block content %} -
    -

    Welcome to your pet organizer

    -
    +
    +

    Welcome to your pet organizer

    {% if user.is_authenticated %} diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/create_notify.html b/src/ahc/apps/medical_notes/templates/medical_notes/create_notify.html deleted file mode 100644 index c1a8883..0000000 --- a/src/ahc/apps/medical_notes/templates/medical_notes/create_notify.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends "homepage/base.html" %} -{% load static %} -{% block extra_js %} - {% if form_name == 'MedicalRecordForm' %} - - {% elif form_name == 'BiometricRecordForm' %} - - {% endif %} -{% endblock %} -{% block content %} -
    -
    - {% csrf_token %} -
    - Register a new note related with: - {% include "partials/form_fields.html" %} -
    - -
    -
    - - Return to the pet profile - -
    -{% endblock %} diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/feeding_notes_list.html b/src/ahc/apps/medical_notes/templates/medical_notes/feeding_notes_list.html index 9a2e2da..ee55fd7 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/feeding_notes_list.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/feeding_notes_list.html @@ -29,29 +29,6 @@

    {{ note.product_name }}

    Return to animal profile
    - {% if notes.paginator.page_range|length > 1 %} - - {% endif %}
    {% endblock %} diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/full_timeline_of_notes.html b/src/ahc/apps/medical_notes/templates/medical_notes/full_timeline_of_notes.html index 5a1ad8e..02fb123 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/full_timeline_of_notes.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/full_timeline_of_notes.html @@ -94,29 +94,7 @@

    Appendixes:

    - {% if paginator.page_range|length > 1 %} - - {% endif %} + {% include "partials/pagination.html" %}
    {% endblock %} diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/notification_list.html b/src/ahc/apps/medical_notes/templates/medical_notes/notification_list.html index 19f0adf..47031cd 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/notification_list.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/notification_list.html @@ -2,9 +2,7 @@ {% load custom_to_class_name %} {% block content %} -
    -

    Notifications for this record

    -
    +

    Notifications for this record

    {% for note in notifications %}
    diff --git a/src/ahc/apps/medical_notes/views/type_feeding_notes.py b/src/ahc/apps/medical_notes/views/type_feeding_notes.py index c585571..92b0504 100644 --- a/src/ahc/apps/medical_notes/views/type_feeding_notes.py +++ b/src/ahc/apps/medical_notes/views/type_feeding_notes.py @@ -97,7 +97,7 @@ def test_func(self): class CreateNotificationView(LoginRequiredMixin, UserPassesTestMixin, FormView): - template_name = "medical_notes/create_notify.html" + template_name = "medical_notes/create.html" form_class = NotificationRecordForm success_url = "/" diff --git a/src/ahc/apps/users/templates/users/login_success.html b/src/ahc/apps/users/templates/users/login_success.html index a67a0b3..6b9ea7a 100644 --- a/src/ahc/apps/users/templates/users/login_success.html +++ b/src/ahc/apps/users/templates/users/login_success.html @@ -1,21 +1,4 @@ {% extends "homepage/base.html" %} {% block content %} -
    -
    - User's profile picture -
    -

    {{ user.username }}

    -

    {{ user.email }}

    -
    -
    -
    - {% csrf_token %} -
    - Profile info - {% include "partials/form_fields.html" with form=user_form %} - {% include "partials/form_fields.html" with form=profile_update %} -
    - -
    -
    + {% include "partials/user_profile_section.html" %} {% endblock %} diff --git a/src/ahc/apps/users/templates/users/password_reset_complete.html b/src/ahc/apps/users/templates/users/password_reset_complete.html index c7160eb..e7e743a 100644 --- a/src/ahc/apps/users/templates/users/password_reset_complete.html +++ b/src/ahc/apps/users/templates/users/password_reset_complete.html @@ -1,8 +1,6 @@ {% extends "homepage/base.html" %} -{%block content%} - -
    -

    Your password has been changed successfully. Please Login

    -
    - -{%endblock content%} +{% block content %} +
    +

    Your password has been changed successfully. Log in

    +
    +{% endblock %} diff --git a/src/ahc/apps/users/templates/users/password_reset_done.html b/src/ahc/apps/users/templates/users/password_reset_done.html index f92eef6..bf16e76 100644 --- a/src/ahc/apps/users/templates/users/password_reset_done.html +++ b/src/ahc/apps/users/templates/users/password_reset_done.html @@ -1,9 +1,7 @@ {% extends "homepage/base.html" %} -{%block content%} - -
    -

    Reset Password

    -

    Please check your inbox and follow the instruction to reset your password.

    -
    - -{%endblock content%} +{% block content %} +
    +

    Reset Password

    +

    Please check your inbox and follow the instructions to reset your password.

    +
    +{% endblock %} diff --git a/src/ahc/apps/users/templates/users/profile.html b/src/ahc/apps/users/templates/users/profile.html index a67a0b3..6b9ea7a 100644 --- a/src/ahc/apps/users/templates/users/profile.html +++ b/src/ahc/apps/users/templates/users/profile.html @@ -1,21 +1,4 @@ {% extends "homepage/base.html" %} {% block content %} -
    -
    - User's profile picture -
    -

    {{ user.username }}

    -

    {{ user.email }}

    -
    -
    -
    - {% csrf_token %} -
    - Profile info - {% include "partials/form_fields.html" with form=user_form %} - {% include "partials/form_fields.html" with form=profile_update %} -
    - -
    -
    + {% include "partials/user_profile_section.html" %} {% endblock %} diff --git a/static/css/custom_pico.css b/static/css/custom_pico.css index 466cf35..0ef9def 100644 --- a/static/css/custom_pico.css +++ b/static/css/custom_pico.css @@ -14,6 +14,11 @@ --pico-secondary-inverse: #000; } +/* Centered section/page headings */ +.welcome-heading { + text-align: center; +} + /* Animal card: background image overlay button */ .btn-with-bg { background-size: cover; diff --git a/templates/partials/animal_card.html b/templates/partials/animal_card.html index 37c132d..8b93069 100644 --- a/templates/partials/animal_card.html +++ b/templates/partials/animal_card.html @@ -1,5 +1,14 @@ +{% if animal.profile_image %} {{ animal.full_name }} +{% else %} +{{ animal.full_name }} +{% endif %} diff --git a/templates/partials/pagination.html b/templates/partials/pagination.html new file mode 100644 index 0000000..d0b9e19 --- /dev/null +++ b/templates/partials/pagination.html @@ -0,0 +1,23 @@ +{% if paginator.page_range|length > 1 %} + +{% endif %} diff --git a/templates/partials/user_profile_section.html b/templates/partials/user_profile_section.html new file mode 100644 index 0000000..cb067ec --- /dev/null +++ b/templates/partials/user_profile_section.html @@ -0,0 +1,18 @@ +
    +
    + User's profile picture +
    +

    {{ user.username }}

    +

    {{ user.email }}

    +
    +
    +
    + {% csrf_token %} +
    + Profile info + {% include "partials/form_fields.html" with form=user_form %} + {% include "partials/form_fields.html" with form=profile_update %} +
    + +
    +
    From 67204e1e62c9c522cb5520e9b08c0758819c4a19 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 31 May 2026 13:55:03 +0200 Subject: [PATCH 03/15] refactor(toolchain): align dev toolchain with project audi --- .gitattributes | 23 +- .github/workflows/django.yml | 30 ++- .gitignore | 8 + .pre-commit-config.yaml | 37 ++- TODO.md | 27 +++ doc/00_ADR-subject.md.template | 23 ++ docker/Dockerfile-web | 1 + docker/Dockerfile-web.dockerignore | 8 + justfile | 11 +- pyproject.toml | 52 +++- src/ahc/apps/animals/utils_owner/forms.py | 7 +- .../medical_notes/forms/type_basic_note.py | 4 +- .../medical_notes/views/type_basic_note.py | 2 +- .../medical_notes/views/type_feeding_notes.py | 6 +- .../views/type_measurement_notes.py | 3 +- src/ahc/apps/users/forms.py | 3 +- .../utils/sending_utils.py | 2 +- uv.lock | 224 +++++++++++++++++- 18 files changed, 421 insertions(+), 50 deletions(-) create mode 100644 doc/00_ADR-subject.md.template diff --git a/.gitattributes b/.gitattributes index e4109b1..c51004c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,14 +1,17 @@ -# Auto detect text files and perform LF normalization -* text=auto +* text eol=lf -# Vendor static files — preserve LF line endings as published -static/**/* text eol=lf -static_collected/**/* text eol=lf +*.py text eol=lf +*.toml text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +*.json text eol=lf +*.md text eol=lf +*.lock text eol=lf +Dockerfile* text eol=lf -# Binary assets — must come after the text rules above (last match wins) -*.png binary -*.jpg binary +*.png binary +*.jpg binary *.jpeg binary -*.gif binary -*.ico binary +*.gif binary +*.ico binary *.webp binary diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 6523cdb..70a8c6a 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -7,6 +7,34 @@ on: branches: [ "main" ] jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: "3.14" + + - name: Install dependencies + run: uv sync + + - name: Ruff check + run: uv run ruff check . + + - name: Ruff format check + run: uv run ruff format --check . + + - name: ty check + run: uv run ty check + + - name: Codespell + run: uv run codespell + test: runs-on: ubuntu-latest env: @@ -58,7 +86,7 @@ jobs: build-and-push: name: Build and Push to ECR runs-on: ubuntu-latest - needs: test + needs: [test, lint] if: false # disabled — re-enable when AWS ECR is ready steps: - name: Checkout Repository diff --git a/.gitignore b/.gitignore index 2ed07ae..6b7df3a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ __pycache__/ .coverage .coverage.* htmlcov/ +tests/result/ .pytest_cache/ .hypothesis/ @@ -45,9 +46,11 @@ celerybeat.pid # Claude Code — not tracked in this repository .claude/ CLAUDE.md +/.mcp.json # IDE .idea/ +.vscode/ # Project-specific /static_collected/ @@ -62,3 +65,8 @@ secret.yaml # Volumes /db/ + +# Local dev workspace +/temp/ +/log/* +!/log/*.template diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8afd159..9ad7d8e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,4 @@ +default_install_hook_types: [pre-commit, post-merge] exclude: 'static/|static_collected/' repos: @@ -8,7 +9,7 @@ repos: entry: uv lock --check language: system pass_filenames: false - files: ^pyproject\.toml$ + files: ^(pyproject\.toml|uv\.lock)$ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 @@ -17,21 +18,35 @@ repos: exclude: '\.sh$' - id: trailing-whitespace -- repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.14 +- repo: local hooks: - id: ruff-check + name: ruff check + entry: uv run ruff check + language: system + types_or: [python, pyi] + pass_filenames: false + always_run: true - id: ruff-format - args: [--check] - -- repo: https://github.com/codespell-project/codespell - rev: v2.4.2 - hooks: + name: ruff format + entry: uv run ruff format --check + language: system + types_or: [python, pyi] + pass_filenames: false + always_run: true - id: codespell + name: codespell + entry: uv run codespell + language: system types_or: [python, markdown, yaml] - -- repo: local - hooks: + pass_filenames: false + always_run: true + - id: ty-check + name: ty check + entry: uv run ty check + language: system + pass_filenames: false + always_run: true - id: bandit name: bandit entry: bandit -r src -c pyproject.toml -q diff --git a/TODO.md b/TODO.md index 2d2bccc..14586b9 100644 --- a/TODO.md +++ b/TODO.md @@ -54,3 +54,30 @@ risk of changing form validation error messages. `animals/views.py` and `medical_notes/views/` contain business logic. Extraction to a service layer is already started. Keep signal decisions (§1) in sync with this work to avoid duplicating logic. + +## 6. Replace `[[tool.ty.overrides]]` with a typed request + +`pyproject.toml` suppresses `unresolved-attribute` across all view/mixin/signal/form +modules to silence Django ORM false positives (mainly `request.user.profile`). +The proper fix is a custom request type in `src/ahc/types.py`: + +```python +# src/ahc/types.py +from typing import TYPE_CHECKING +from django.contrib.auth.models import User +from django.http import HttpRequest + +if TYPE_CHECKING: + from ahc.apps.users.models import Profile + +class _AHCUser(User): + profile: "Profile" + +class AuthenticatedRequest(HttpRequest): + user: _AHCUser # type: ignore[assignment] +``` + +Then annotate each view class: `request: AuthenticatedRequest`. +Once all views are covered, remove the `[[tool.ty.overrides]]` block from +`pyproject.toml`. **Do this as part of the fat-views refactor (§5)** — each +view touched during extraction gets the annotation added. diff --git a/doc/00_ADR-subject.md.template b/doc/00_ADR-subject.md.template new file mode 100644 index 0000000..070eb80 --- /dev/null +++ b/doc/00_ADR-subject.md.template @@ -0,0 +1,23 @@ +## [Short title describing the decision] + +### Date: +`YYYY-MM-DD` + +### Status +[Proposed | In-building | Done | Deprecated | Superseded by ADR-XX] + +### Context +[What is the issue that motivates this decision or change? Describe the forces at play including technological, political, social, and project-local context.] + +### Decision +[What is the change that we're proposing or have agreed to implement?] + +### Consequences +[What becomes easier or more difficult as a result of this decision?] + +### Keywords +- [keyword1], +- [keyword2]. + +### Links +[Links to related ADRs, external resources, or discussions.] diff --git a/docker/Dockerfile-web b/docker/Dockerfile-web index 174d707..36f9eed 100644 --- a/docker/Dockerfile-web +++ b/docker/Dockerfile-web @@ -20,3 +20,4 @@ ENV PATH="/opt/venv/bin:$PATH" \ COPY --chown=appuser:appuser . . USER appuser +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/docker/Dockerfile-web.dockerignore b/docker/Dockerfile-web.dockerignore index b4c0bc8..7197fa2 100644 --- a/docker/Dockerfile-web.dockerignore +++ b/docker/Dockerfile-web.dockerignore @@ -42,3 +42,11 @@ kubernetes/ *.md doc/ + +justfile +.pre-commit-config.yaml +.gitattributes +.vscode/ +tests/ +temp/ +log/ diff --git a/justfile b/justfile index 7e345ac..3f8af7f 100644 --- a/justfile +++ b/justfile @@ -8,9 +8,10 @@ help: install: uv sync -# Run all linters (ruff check, codespell, bandit) +# Run all linters (ruff check, ty, codespell, bandit) lint: uv run ruff check . + uv run ty check uv run codespell uv run bandit -r . -c pyproject.toml -q @@ -54,3 +55,11 @@ down: # Run pre-commit hooks on all files precommit: uv run pre-commit run --all-files + +# Commit with pre-commit checks and commitizen +commit: + uv run pre-commit run && uv run cz commit + +# Bump version using commitizen +bump: + uv run cz bump diff --git a/pyproject.toml b/pyproject.toml index 2ebc62d..2dfd71e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,15 +29,17 @@ dependencies = [ [dependency-groups] dev = [ - "pre-commit", - "ruff", - "ty", - "codespell", + "pre-commit>=4.2.0", + "ruff>=0.11.6", + "ty>=0.0.20", + "codespell>=2.4.1", "bandit[toml]", - "pytest", + "pytest>=9.0.2", "pytest-django", - "pytest-cov", + "pytest-cov>=6.1.1", "icecream", + "commitizen>=4.13.9", + "django-stubs>=5.0", ] [tool.uv] @@ -54,9 +56,15 @@ ignore = [ "RUF012", ] +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["F401", "F841"] +"test_*.py" = ["F401", "F841"] + [tool.ruff.format] quote-style = "double" indent-style = "space" +docstring-code-format = true +docstring-code-line-length = 124 [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "ahc.settings" @@ -71,10 +79,42 @@ markers = [ [tool.ty.environment] python-version = "3.14" +extra-paths = ["src"] + +[tool.ty.src] +exclude = ["src/ahc/apps/*/migrations/*"] [tool.codespell] skip = "uv.lock,./static,./static_collected" +builtin = "clear" +quiet-level = 3 [tool.bandit] exclude_dirs = [".venv"] skips = ["B101", "B404", "B603", "B607"] + +[tool.coverage.run] +data_file = "tests/result/.coverage" + +[[tool.ty.overrides]] +include = [ + "src/ahc/apps/*/views.py", + "src/ahc/apps/*/views/**", + "src/ahc/apps/*/*/views.py", + "src/ahc/apps/*/*/views/**", + "src/ahc/apps/*/mixins/**", + "src/ahc/apps/*/*/mixins/**", + "src/ahc/apps/*/signals.py", + "src/ahc/apps/*/signals/**", + "src/ahc/apps/*/forms.py", + "src/ahc/apps/*/forms/**", +] +rules = { unresolved-attribute = "ignore" } + +[tool.commitizen] +name = "cz_conventional_commits" +version = "0.1.0" +tag_format = "v$version" +version_files = [ + "pyproject.toml:version", +] diff --git a/src/ahc/apps/animals/utils_owner/forms.py b/src/ahc/apps/animals/utils_owner/forms.py index 6b75907..0e29669 100644 --- a/src/ahc/apps/animals/utils_owner/forms.py +++ b/src/ahc/apps/animals/utils_owner/forms.py @@ -74,10 +74,11 @@ def clean_input_user(self): if input_user in self.instance.allowed_users.all(): 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 + input_user_id = profile.pk return input_user_id @@ -92,7 +93,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 diff --git a/src/ahc/apps/medical_notes/forms/type_basic_note.py b/src/ahc/apps/medical_notes/forms/type_basic_note.py index d7133db..8544d56 100644 --- a/src/ahc/apps/medical_notes/forms/type_basic_note.py +++ b/src/ahc/apps/medical_notes/forms/type_basic_note.py @@ -72,7 +72,7 @@ def clean(self): cleaned_data = super().clean() additional_animals = cleaned_data.get("additional_animals") - if self.animal in additional_animals: + if additional_animals is not None and self.animal in additional_animals: raise forms.ValidationError("The main Animal cannot be selected as an additional animal.") return cleaned_data @@ -101,7 +101,7 @@ def clean(self): animal = cleaned_data.get("animal") additional_animals = cleaned_data.get("additional_animals") - if animal in additional_animals: + if additional_animals is not None and animal in additional_animals: raise forms.ValidationError("The main Animal cannot be selected as an additional animal.") return cleaned_data 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 b533130..f41118f 100644 --- a/src/ahc/apps/medical_notes/views/type_basic_note.py +++ b/src/ahc/apps/medical_notes/views/type_basic_note.py @@ -119,7 +119,7 @@ def post(self, request, *args, **kwargs): messages.error(request, f"Failed to upload. {exc}") else: for _field, errors in form.errors.items(): - messages.error(request, f"Failed to upload: {', '.join(errors)}") + messages.error(request, f"Failed to upload: {', '.join(str(e) for e in errors)}") return redirect(request.path) diff --git a/src/ahc/apps/medical_notes/views/type_feeding_notes.py b/src/ahc/apps/medical_notes/views/type_feeding_notes.py index 92b0504..0d236a1 100644 --- a/src/ahc/apps/medical_notes/views/type_feeding_notes.py +++ b/src/ahc/apps/medical_notes/views/type_feeding_notes.py @@ -1,7 +1,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.http import HttpResponseRedirect -from django.shortcuts import get_object_or_404, redirect, reverse -from django.urls import reverse_lazy +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse, reverse_lazy from django.views.generic.edit import FormView, UpdateView from django.views.generic.list import ListView @@ -65,7 +65,7 @@ def form_valid(self, form): medical_record = feeding_note.related_note success_url = reverse_lazy("note_related_diets", kwargs={"pk": medical_record.id}) - return redirect(success_url) + return redirect(str(success_url)) def get_success_url(self): note = get_object_or_404(MedicalRecord, pk=self.kwargs.get("pk")) 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 90dfeca..beb2f22 100644 --- a/src/ahc/apps/medical_notes/views/type_measurement_notes.py +++ b/src/ahc/apps/medical_notes/views/type_measurement_notes.py @@ -1,5 +1,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin -from django.shortcuts import get_object_or_404, redirect, reverse +from django.shortcuts import get_object_or_404, redirect +from django.urls import reverse from django.views.generic.edit import FormView from ahc.apps.animals.models import Animal as AnimalProfile diff --git a/src/ahc/apps/users/forms.py b/src/ahc/apps/users/forms.py index acc0b2f..f97d2de 100644 --- a/src/ahc/apps/users/forms.py +++ b/src/ahc/apps/users/forms.py @@ -1,5 +1,6 @@ from django import forms -from django.contrib.auth.forms import User, UserCreationForm +from django.contrib.auth.forms import UserCreationForm +from django.contrib.auth.models import User from ahc.apps.users.models import Profile diff --git a/src/celery_notifications/utils/sending_utils.py b/src/celery_notifications/utils/sending_utils.py index fe3de81..63b7b33 100644 --- a/src/celery_notifications/utils/sending_utils.py +++ b/src/celery_notifications/utils/sending_utils.py @@ -11,7 +11,7 @@ def standardize_message_size(message: str, max_length: int = 2500) -> str: def send_via_email(**kwargs): _recipient_list = kwargs.get("email") _subject = kwargs.get("subject") - message = kwargs.get("message") + message = kwargs.get("message", "") message = standardize_message_size(message, max_length=2500) sender_email = settings.EMAIL_HOST_USER diff --git a/uv.lock b/uv.lock index 2e54726..0646ed7 100644 --- a/uv.lock +++ b/uv.lock @@ -117,6 +117,8 @@ dependencies = [ dev = [ { name = "bandit" }, { name = "codespell" }, + { name = "commitizen" }, + { name = "django-stubs" }, { name = "icecream" }, { name = "pre-commit" }, { name = "pytest" }, @@ -153,14 +155,25 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "bandit", extras = ["toml"] }, - { name = "codespell" }, + { name = "codespell", specifier = ">=2.4.1" }, + { name = "commitizen", specifier = ">=4.13.9" }, + { name = "django-stubs", specifier = ">=5.0" }, { name = "icecream" }, - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-cov" }, + { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "pytest-django" }, - { name = "ruff" }, - { name = "ty" }, + { name = "ruff", specifier = ">=0.11.6" }, + { name = "ty", specifier = ">=0.0.20" }, +] + +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] [[package]] @@ -433,6 +446,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "commitizen" +version = "4.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "charset-normalizer" }, + { name = "colorama" }, + { name = "decli" }, + { name = "deprecated" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "prompt-toolkit" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "termcolor" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/cc/d87b094ef858c67febcd1d8902352c84b42c9ebc8221d6f2e9d553273358/commitizen-4.16.3.tar.gz", hash = "sha256:5cdca4c02715cc770312f4b505c65a6c39024c73ece41b943bccaf81c44436ed", size = 66772, upload-time = "2026-05-30T06:34:21.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/35/c7995b1e66159193dd31ed5628d59acbaf4611811645eedf0fb2d5a91946/commitizen-4.16.3-py3-none-any.whl", hash = "sha256:ce1be39fe98a16725fd0c960daf0f360acac86db7ae8db1e1df8d3541005b5be", size = 88927, upload-time = "2026-05-30T06:34:20.006Z" }, +] + [[package]] name = "coverage" version = "7.14.0" @@ -525,6 +561,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, ] +[[package]] +name = "decli" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/59/d4ffff1dee2c8f6f2dd8f87010962e60f7b7847504d765c91ede5a466730/decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656", size = 7564, upload-time = "2025-06-01T15:23:41.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989, upload-time = "2025-06-01T15:23:40.228Z" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -534,6 +579,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "discord" version = "2.3.2" @@ -582,6 +639,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/5b/1328f8b84fce040c404f76822bf8c57d254e368e8cbd8bd67ec2b26d75f5/django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0", size = 8368680, upload-time = "2026-05-05T13:54:33.532Z" }, ] +[[package]] +name = "django-stubs" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-stubs-ext" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/8a/8946216758eb66c5700a235e230af538d4ea474244c79452159b580425ba/django_stubs-6.0.5.tar.gz", hash = "sha256:7742b8e60afc68a8545158e2bdb103376d5a092f7902c17f370920a9c08eb957", size = 280490, upload-time = "2026-05-25T08:47:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/09/64ff51a4cf4e8bdf8423d249b32eca0676e69233009ce9cd5478ba2c9635/django_stubs-6.0.5-py3-none-any.whl", hash = "sha256:9fb9eede9b2fc47b36c3dc4a93652eb959dff45466ec4a580e4a22782192d746", size = 544132, upload-time = "2026-05-25T08:47:00.332Z" }, +] + +[[package]] +name = "django-stubs-ext" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/01/419bc3cd882f3ec645d5a4511085202dd6bd3ef8d385871dcd2d32cc15b3/django_stubs_ext-6.0.5.tar.gz", hash = "sha256:1cc325e991303849bce70e19748981b225ef08b37256f263e113caf97cd3bcc0", size = 6666, upload-time = "2026-05-25T08:46:36.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/bf/7ee71071d84ad4e0efcd77e2681afed254a5f65c27524441a9caf8c60467/django_stubs_ext-6.0.5-py3-none-any.whl", hash = "sha256:a9932c8233d6dd4e34ae0b192026379c1a9be8f0b7c27aa1fa09ae215169773e", size = 10362, upload-time = "2026-05-25T08:46:34.467Z" }, +] + [[package]] name = "django-taggit" version = "6.1.0" @@ -744,6 +829,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "kombu" version = "5.6.2" @@ -771,6 +868,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -930,14 +1057,14 @@ wheels = [ [[package]] name = "prompt-toolkit" -version = "3.0.52" +version = "3.0.51" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, ] [[package]] @@ -1168,6 +1295,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + [[package]] name = "redis" version = "7.4.0" @@ -1270,6 +1409,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/ac/19f9941c74add59d17694930ec8105d5eddeee4ce56dd8632b765ca16d6c/stevedore-5.8.0-py3-none-any.whl", hash = "sha256:88eede9e66ca80e34085b9174e2327da2c61ac91f24f70e41c3ad76e4bb4872b", size = 54553, upload-time = "2026-05-18T09:15:25.82Z" }, ] +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + [[package]] name = "tornado" version = "6.5.5" @@ -1312,6 +1469,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/17/ae7339651bfcaa5f54698c8c70eaf5031baa400ecb67baec31d03a56cbd4/ty-0.0.39-py3-none-win_arm64.whl", hash = "sha256:eb4cf0fefbbfedf9a352597bb2431ebdcb7eb3a595c0f825f228e897a0ec285d", size = 11081409, upload-time = "2026-05-22T21:09:03.741Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + [[package]] name = "tzdata" version = "2026.2" @@ -1375,6 +1550,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, ] +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + [[package]] name = "yarl" version = "1.24.2" From 7d80a737511bd1aae3d5a38e52d52034a7dcd896 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 31 May 2026 14:53:17 +0200 Subject: [PATCH 04/15] refactor(docker): consolidate Dockerfiles and add local dev workflow --- .gitattributes | 1 + README.md | 6 +- docker/{Dockerfile-queue => Dockerfile-app} | 0 ...ckerignore => Dockerfile-app.dockerignore} | 3 +- docker/Dockerfile-queue.dockerignore | 45 ------- docker/Dockerfile-web | 23 ---- docker/docker-compose.yml | 14 ++- justfile | 16 ++- kubernetes/backend/web/deployment.yaml | 2 +- .../homepage/templates/homepage/base.html | 14 ++- static/css/custom_pico.css | 116 ++++++++++++++++-- static/css/stable_grid.css | 2 +- templates/partials/animal_card.html | 21 ++-- 13 files changed, 154 insertions(+), 109 deletions(-) rename docker/{Dockerfile-queue => Dockerfile-app} (100%) rename docker/{Dockerfile-web.dockerignore => Dockerfile-app.dockerignore} (78%) delete mode 100644 docker/Dockerfile-queue.dockerignore delete mode 100644 docker/Dockerfile-web diff --git a/.gitattributes b/.gitattributes index c51004c..cb58941 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,7 @@ * text eol=lf *.py text eol=lf +*.sh text eol=lf *.toml text eol=lf *.yaml text eol=lf *.yml text eol=lf diff --git a/README.md b/README.md index 8bc0102..f060c42 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,7 @@ just docker-up - Example commands: ``` docker-compose build - docker image save -o ahc_app-web.tar ahc_app-web:latest - docker image save -o ahc_app-queue.tar ahc_app-queue:latest + docker image save -o ahc-app.tar ahc-app:latest docker image save -o ahc_app-couch_db.tar ahc_app-couch_db:latest docker image save -o postgres.tar postgres:18-alpine ``` @@ -110,8 +109,7 @@ just docker-up - Push the Docker images to a container registry, - Example using Minikube: ``` - minikube image load ahc_app-web.tar - minikube image load ahc_app-queue.tar + minikube image load ahc-app.tar minikube image load ahc_app-couch_db.tar minikube image load postgres.tar # postgres:18-alpine ``` diff --git a/docker/Dockerfile-queue b/docker/Dockerfile-app similarity index 100% rename from docker/Dockerfile-queue rename to docker/Dockerfile-app diff --git a/docker/Dockerfile-web.dockerignore b/docker/Dockerfile-app.dockerignore similarity index 78% rename from docker/Dockerfile-web.dockerignore rename to docker/Dockerfile-app.dockerignore index 7197fa2..8f9fdf1 100644 --- a/docker/Dockerfile-web.dockerignore +++ b/docker/Dockerfile-app.dockerignore @@ -1,4 +1,5 @@ -# Build-context exclusions for Dockerfile-web (context = project root) +# Build-context exclusions for Dockerfile-app (context = project root) +# Used for web, Celery worker, and Celery Beat services. .venv/ diff --git a/docker/Dockerfile-queue.dockerignore b/docker/Dockerfile-queue.dockerignore deleted file mode 100644 index c884204..0000000 --- a/docker/Dockerfile-queue.dockerignore +++ /dev/null @@ -1,45 +0,0 @@ -# Build-context exclusions for Dockerfile-queue (context = project root) -# Used for both the Celery worker and Celery Beat services. - -.venv/ - -__pycache__/ -*.py[cod] -*.pyo - -.pytest_cache/ -.hypothesis/ -htmlcov/ -.coverage -.coverage.* - -.mypy_cache/ -.ty_cache/ -.ruff_cache/ - -db.sqlite3 -db.sqlite3-journal -local_settings.py -static_collected/ - -static/media/profile_pics/ -static/media/attachments/ - -.env -.env.* - -node_modules/ - -tars/ -*.tar - -.git/ -.gitignore -.idea/ -.claude/ -CLAUDE.md - -kubernetes/ - -*.md -doc/ diff --git a/docker/Dockerfile-web b/docker/Dockerfile-web deleted file mode 100644 index 36f9eed..0000000 --- a/docker/Dockerfile-web +++ /dev/null @@ -1,23 +0,0 @@ -# syntax=docker/dockerfile:1.7 -FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder -WORKDIR /app -COPY pyproject.toml uv.lock ./ -ENV UV_PYTHON_PREFERENCE=only-system \ - UV_PROJECT_ENVIRONMENT=/opt/venv -RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked \ - uv sync --no-group dev - -FROM python:3.14-slim AS runtime -LABEL authors="AM" - -RUN groupadd --gid 1000 appuser \ - && useradd --uid 1000 --gid 1000 --no-create-home appuser - -WORKDIR /app -COPY --from=builder /opt/venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" \ - PYTHONPATH=/app/src - -COPY --chown=appuser:appuser . . -USER appuser -CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 82f7ee0..ba92388 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -4,8 +4,8 @@ services: web: build: context: .. - dockerfile: docker/Dockerfile-web - image: ahc_app-web:latest + dockerfile: docker/Dockerfile-app + image: ahc-app:latest ports: - "8000:8000" volumes: @@ -14,6 +14,8 @@ services: - ../.env environment: - PYTHONUNBUFFERED=1 + - DB_HOST=postgres_db + - DB_PORT=5432 depends_on: postgres_db: condition: service_healthy @@ -80,13 +82,15 @@ services: queue: build: context: .. - dockerfile: docker/Dockerfile-queue + dockerfile: docker/Dockerfile-app command: celery -A celery_notifications.config:celery_obj worker -l info env_file: - ../.env environment: - DJANGO_SETTINGS_MODULE=ahc.settings - PYTHONUNBUFFERED=1 + - DB_HOST=postgres_db + - DB_PORT=5432 depends_on: redis: condition: service_healthy @@ -109,13 +113,15 @@ services: celery_beat: build: context: .. - dockerfile: docker/Dockerfile-queue + dockerfile: docker/Dockerfile-app command: celery -A celery_notifications.config:celery_obj beat -l info env_file: - ../.env environment: - DJANGO_SETTINGS_MODULE=ahc.settings - PYTHONUNBUFFERED=1 + - DB_HOST=postgres_db + - DB_PORT=5432 depends_on: redis: condition: service_healthy diff --git a/justfile b/justfile index 3f8af7f..0da6c82 100644 --- a/justfile +++ b/justfile @@ -44,7 +44,7 @@ migrate: shell: uv run python manage.py shell -# Start all Docker services +# Start all Docker services (full stack, with rebuild) up: docker-compose --env-file .env -f docker/docker-compose.yml up -d --build @@ -52,6 +52,20 @@ up: down: docker-compose --env-file .env -f docker/docker-compose.yml down +# Start only infrastructure services (Postgres, Redis, CouchDB) — no app rebuild +infra: + docker-compose --env-file .env -f docker/docker-compose.yml up -d postgres_db redis couch_db + +# Stop infrastructure services +infra-down: + docker-compose --env-file .env -f docker/docker-compose.yml stop postgres_db redis couch_db + +# Local dev: wait for healthy infra, migrate, run Django with hot-reload +dev: + docker-compose --env-file .env -f docker/docker-compose.yml up -d --wait postgres_db redis couch_db + uv run python manage.py migrate + uv run python manage.py runserver + # Run pre-commit hooks on all files precommit: uv run pre-commit run --all-files diff --git a/kubernetes/backend/web/deployment.yaml b/kubernetes/backend/web/deployment.yaml index d24b60f..7eef713 100644 --- a/kubernetes/backend/web/deployment.yaml +++ b/kubernetes/backend/web/deployment.yaml @@ -17,7 +17,7 @@ spec: spec: containers: - name: ahc-app-backend - image: ahc_app-web:latest + image: ahc-app:latest imagePullPolicy: Never env: - name: PYTHONUNBUFFERED diff --git a/src/ahc/apps/homepage/templates/homepage/base.html b/src/ahc/apps/homepage/templates/homepage/base.html index 1bffd91..1eb3b55 100644 --- a/src/ahc/apps/homepage/templates/homepage/base.html +++ b/src/ahc/apps/homepage/templates/homepage/base.html @@ -15,6 +15,10 @@ + + + + @@ -22,13 +26,13 @@ -
    -
    -
    + diff --git a/static/css/custom_pico.css b/static/css/custom_pico.css index 0ef9def..245ba7e 100644 --- a/static/css/custom_pico.css +++ b/static/css/custom_pico.css @@ -1,8 +1,7 @@ /* Custom overrides on top of Pico 2.1.1 yellow theme. * * Secondary color: neutral grey #bbbbbb (instead of the default blue-grey). - * --pico-secondary-border / --pico-secondary-hover-border inherit from - * --pico-secondary / --pico-secondary-hover and do not need explicit overrides. + * Fonts: Bricolage Grotesque (headings) + DM Sans (body). */ :root { --pico-secondary: #bbbbbb; @@ -12,6 +11,19 @@ --pico-secondary-focus: rgba(187, 187, 187, 0.25); --pico-secondary-underline: rgba(187, 187, 187, 0.5); --pico-secondary-inverse: #000; + + --ahc-font-display: 'Bricolage Grotesque', system-ui, sans-serif; + --ahc-font-body: 'DM Sans', system-ui, sans-serif; +} + +body { + font-family: var(--ahc-font-body); +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--ahc-font-display); + font-weight: 700; + letter-spacing: -0.02em; } /* Centered section/page headings */ @@ -19,18 +31,98 @@ text-align: center; } -/* Animal card: background image overlay button */ -.btn-with-bg { - background-size: cover; - background-position: center; - min-height: 8rem; - display: flex; - align-items: flex-end; - padding: 0.5rem; -} - /* Form validation error hint */ .form-error { color: var(--pico-color-red-500, #e53e3e); display: block; } + +/* Site header: Pico's .grid provides the 2-col layout; we add height + clip */ +.site-header { + padding-block: 0; +} + +.site-header__inner { + height: 120px; + overflow: hidden; + align-items: center; +} + +.site-header__brand { + display: flex; + align-items: center; +} + +.site-header__brand a { + text-decoration: none; +} + +.site-header__brand h2 { + margin: 0; + font-weight: 800; + letter-spacing: -0.03em; +} + +.site-header__banner { + height: 120px; + overflow: hidden; +} + +/* height: 120px beats Pico's img { height: auto } (higher specificity: 0,1,1 vs 0,0,1) */ +.site-header__banner img { + width: 100%; + height: 120px; + object-fit: cover; + object-position: 50% 30%; + display: block; +} + +/* Animal card: 48×48 thumbnail on left, name on right */ +.animal-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.875rem; + text-decoration: none; + color: var(--pico-color); + background: var(--pico-card-background-color); + border: 1px solid var(--pico-muted-border-color); + border-radius: var(--pico-border-radius); + transition: border-color 0.15s, background 0.15s; +} + +.animal-card:hover { + border-color: var(--pico-primary); + background: var(--pico-card-sectioning-background-color); + color: var(--pico-color); + text-decoration: none; +} + +.animal-card__thumb { + flex-shrink: 0; + width: 48px; + height: 48px; + border-radius: 6px; + overflow: hidden; + background: var(--pico-muted-border-color); + position: relative; +} + +/* absolute fill bypasses Pico's img { height: auto } entirely */ +.animal-card__thumb img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.animal-card__name { + font-family: var(--ahc-font-display); + font-weight: 600; + font-size: 0.9375rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/static/css/stable_grid.css b/static/css/stable_grid.css index 4582e42..e103448 100644 --- a/static/css/stable_grid.css +++ b/static/css/stable_grid.css @@ -1,6 +1,6 @@ .stable_grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-gap: 20px; } diff --git a/templates/partials/animal_card.html b/templates/partials/animal_card.html index 8b93069..18e9018 100644 --- a/templates/partials/animal_card.html +++ b/templates/partials/animal_card.html @@ -1,14 +1,11 @@ -{% if animal.profile_image %} -{{ animal.full_name }} -{% else %} -{{ animal.full_name }} -{% endif %} +> +
    + {% if animal.profile_image %} + + {% endif %} +
    + {{ animal.full_name }} + From 6ee756f025dbd4ee9157109b115ae74b8811c379 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 31 May 2026 14:56:50 +0200 Subject: [PATCH 05/15] fix(frontend): position of footer --- static/css/custom_pico.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/static/css/custom_pico.css b/static/css/custom_pico.css index 245ba7e..ab17803 100644 --- a/static/css/custom_pico.css +++ b/static/css/custom_pico.css @@ -18,6 +18,13 @@ body { font-family: var(--ahc-font-body); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +main { + flex: 1; } h1, h2, h3, h4, h5, h6 { From 1aeee322c4d9f4a91916ebd0c532c2203df5a015 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 31 May 2026 15:03:00 +0200 Subject: [PATCH 06/15] fix(frontend): timeline section --- src/ahc/apps/animals/templates/animals/profile.html | 4 +++- static/css/timeline.css | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/ahc/apps/animals/templates/animals/profile.html b/src/ahc/apps/animals/templates/animals/profile.html index 7002d61..6b4f589 100644 --- a/src/ahc/apps/animals/templates/animals/profile.html +++ b/src/ahc/apps/animals/templates/animals/profile.html @@ -53,12 +53,13 @@

    E

    Last records:

    - + {% if recent_records %}
      {% for record in recent_records reversed %}
    1. @@ -70,6 +71,7 @@

      Last records:

      {% endfor %}
    + {% endif %}

    diff --git a/static/css/timeline.css b/static/css/timeline.css index 8b88631..e2254f9 100644 --- a/static/css/timeline.css +++ b/static/css/timeline.css @@ -78,6 +78,12 @@ color: var(--timeline-crystal); } +.records-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + .timeline ol::-webkit-scrollbar { height: 12px; } From 77e56cceaba60fc85e319b53755629025e425aad Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 31 May 2026 15:33:23 +0200 Subject: [PATCH 07/15] fix(frontend): flexbox for picture and short info --- .../animals/templates/animals/profile.html | 6 ++-- static/css/custom_pico.css | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/ahc/apps/animals/templates/animals/profile.html b/src/ahc/apps/animals/templates/animals/profile.html index 6b4f589..42cc1e9 100644 --- a/src/ahc/apps/animals/templates/animals/profile.html +++ b/src/ahc/apps/animals/templates/animals/profile.html @@ -12,13 +12,13 @@ {% endblock %} {% block content %}
    -
    - +
    + Animal's profile picture -
    +

    {{ animal.full_name }}

    {% if animal.birthdate %} diff --git a/static/css/custom_pico.css b/static/css/custom_pico.css index ab17803..0877e29 100644 --- a/static/css/custom_pico.css +++ b/static/css/custom_pico.css @@ -84,6 +84,36 @@ h1, h2, h3, h4, h5, h6 { display: block; } +/* Animal profile hero: image left, info right */ +.animal-profile-hero { + display: flex; + align-items: flex-start; + gap: 1.5rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.animal-profile-hero__img { + flex-shrink: 0; +} + +.animal-profile-hero__img img { + width: 180px; + height: 180px; + object-fit: cover; + border-radius: 8px; + display: block; +} + +.animal-profile-hero__info { + flex: 1; + min-width: 180px; +} + +.animal-profile-hero__info h2 { + margin-top: 0; +} + /* Animal card: 48×48 thumbnail on left, name on right */ .animal-card { display: flex; From 8d38df1534321f4286d8235e57624a2847e28289 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Sun, 31 May 2026 15:34:39 +0200 Subject: [PATCH 08/15] feat(frontend): section for tabs --- .../homepage/templates/homepage/base.html | 2 + static/css/custom_pico.css | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/ahc/apps/homepage/templates/homepage/base.html b/src/ahc/apps/homepage/templates/homepage/base.html index 1eb3b55..bba9d54 100644 --- a/src/ahc/apps/homepage/templates/homepage/base.html +++ b/src/ahc/apps/homepage/templates/homepage/base.html @@ -37,6 +37,8 @@
    +{% block tab_nav %}{% endblock %} +
    {% if messages %} {% for message in messages %} diff --git a/static/css/custom_pico.css b/static/css/custom_pico.css index 0877e29..7f467f8 100644 --- a/static/css/custom_pico.css +++ b/static/css/custom_pico.css @@ -25,6 +25,49 @@ body { main { flex: 1; + padding-top: 2rem; +} + +/* Tab navigation bar — sits between header and main, override in {% block tab_nav %} */ +.tab-nav { + border-bottom: 1px solid var(--pico-muted-border-color); + background: var(--pico-background-color); + position: sticky; + top: 0; + z-index: 10; +} + +.tab-nav ul { + display: flex; + gap: 0; + margin: 0; + padding: 0 var(--pico-spacing); + list-style: none; + overflow-x: auto; +} + +.tab-nav ul li a { + display: block; + padding: 0.75rem 1.25rem; + font-family: var(--ahc-font-display); + font-size: 0.875rem; + font-weight: 600; + text-decoration: none; + color: var(--pico-muted-color); + border-bottom: 2px solid transparent; + white-space: nowrap; + transition: color 0.15s, border-color 0.15s; +} + +.tab-nav ul li a:hover { + color: var(--pico-color); + border-bottom-color: var(--pico-muted-border-color); + text-decoration: none; +} + +.tab-nav ul li a.active { + color: var(--pico-primary); + border-bottom-color: var(--pico-primary); } h1, h2, h3, h4, h5, h6 { From d2a29b7d82d339656afa2a7d1c2c381d237c54b3 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:01:14 +0200 Subject: [PATCH 09/15] feat: 4 fields on animal model, frontend and migration --- doc/09_adr_user_data.md | 120 +++++------- doc/11_adr_frontend_interactions.md | 52 +++++ ...ext_visit_date_and_dietary_restrictions.py | 22 +++ ...add_species_breed_sex_and_sterilization.py | 34 ++++ src/ahc/apps/animals/models.py | 14 ++ src/ahc/apps/animals/services.py | 28 +++ .../animals/change_animal_details.html | 15 ++ .../animals/change_dietary_restrictions.html | 25 +++ .../templates/animals/change_next_visit.html | 20 ++ .../animals/templates/animals/profile.html | 150 +++++---------- .../animals/templates/animals/tabs/_diet.html | 47 +++++ .../templates/animals/tabs/_mainpage.html | 30 +++ .../templates/animals/tabs/_medications.html | 42 ++++ .../templates/animals/tabs/_notes.html | 43 +++++ .../templates/animals/tabs/_ownership.html | 28 +++ .../templates/animals/tabs/_settings.html | 29 +++ .../animals/templates/animals/tabs/_vet.html | 57 ++++++ src/ahc/apps/animals/tests.py | 182 ++++++++++++++++++ src/ahc/apps/animals/urls.py | 9 + src/ahc/apps/animals/utils_owner/forms.py | 20 ++ src/ahc/apps/animals/utils_owner/views.py | 84 +++++++- src/ahc/apps/animals/views.py | 148 +++++++++++++- .../homepage/templates/homepage/base.html | 16 ++ .../medical_notes/forms/type_basic_note.py | 20 +- .../medical_notes/forms/type_feeding_notes.py | 3 + .../0015_add_feeding_note_purchase_source.py | 17 ++ .../models/type_feeding_notes.py | 1 + src/ahc/apps/medical_notes/selectors.py | 26 +++ .../templates/medical_notes/create.html | 6 +- .../templates/medical_notes/edit.html | 6 +- .../medical_notes/views/type_basic_note.py | 56 +++++- static/css/custom_pico.css | 151 ++++++++++++++- static/js/animal_select.js | 73 +++++++ static/js/expanding_sections.js | 19 +- static/js/hiding_note_fields_in_form.js | 48 +++-- static/js/modal.js | 45 +++++ static/js/pin_animal.js | 91 +++++---- static/js/tabs.js | 45 +++++ static/js/tag_input.js | 113 +++++++++++ static/js/timeline.js | 37 ++-- static/js/vendor/htmx.min.js | 1 + templates/partials/modal_note_form.html | 16 ++ 42 files changed, 1717 insertions(+), 272 deletions(-) create mode 100644 doc/11_adr_frontend_interactions.md create mode 100644 src/ahc/apps/animals/migrations/0002_add_next_visit_date_and_dietary_restrictions.py create mode 100644 src/ahc/apps/animals/migrations/0003_add_species_breed_sex_and_sterilization.py create mode 100644 src/ahc/apps/animals/templates/animals/change_animal_details.html create mode 100644 src/ahc/apps/animals/templates/animals/change_dietary_restrictions.html create mode 100644 src/ahc/apps/animals/templates/animals/change_next_visit.html create mode 100644 src/ahc/apps/animals/templates/animals/tabs/_diet.html create mode 100644 src/ahc/apps/animals/templates/animals/tabs/_mainpage.html create mode 100644 src/ahc/apps/animals/templates/animals/tabs/_medications.html create mode 100644 src/ahc/apps/animals/templates/animals/tabs/_notes.html create mode 100644 src/ahc/apps/animals/templates/animals/tabs/_ownership.html create mode 100644 src/ahc/apps/animals/templates/animals/tabs/_settings.html create mode 100644 src/ahc/apps/animals/templates/animals/tabs/_vet.html create mode 100644 src/ahc/apps/medical_notes/migrations/0015_add_feeding_note_purchase_source.py create mode 100644 static/js/animal_select.js create mode 100644 static/js/modal.js create mode 100644 static/js/tabs.js create mode 100644 static/js/tag_input.js create mode 100644 static/js/vendor/htmx.min.js create mode 100644 templates/partials/modal_note_form.html diff --git a/doc/09_adr_user_data.md b/doc/09_adr_user_data.md index 257e8df..fa66bbf 100644 --- a/doc/09_adr_user_data.md +++ b/doc/09_adr_user_data.md @@ -1,86 +1,66 @@ -## To set a list of stored data and tables structure - +## Data model — stored fields per entity ### Date -`2023-07-09` - +`2023-07-09` (updated `2026-05-31`) ### Status In-building - ### Context -We need to set up a place in documentation to list all collect all data about users and group them by correct place in databases and direct tables. -Main sections of data by sources: -- user, -- animal, -- medical record note, -- medical document, -- medicines, -- medical facility, -- veterinarian, -- dates scheduling, -- costs counting. - +Defines what data is stored per entity in PostgreSQL (primary DB, ADR-08), which fields are +optional vs required, and how the models evolve over time. +This ADR is a living document — update it when new fields are added. ### Decision -User datatables: -- collected by the registration process: - - basic provided by user information: - - name, - - email, - - password, - - auto-collected: - - date of registration, - - default profile image, - - default background image, - - default user privileges (viewer, owner, creator, moderator, admin etc.) -- collected after the registration process: - - provided in profil page: - - profile image, - - background image chosen, - - email-change, - - password-change, - - date of birthday, - - stable view(compedium of animals: owned and cared) - - connections other models: - - animal - owner, viewer, - - medical record note - participation in visit - - medical_place_id, - - note_ - - - - -Medical records (animal timeline) -- animal_id, -- title, -- short_description, -- full_description, -- creation_date, -- modify_date, -- start event date, -- end event date, -- type of events: - - visit, - - period of medicine providing, - - note, - - measurement, - - change of feed, -- participants (user, vet), -- place, -- medicals, - - +#### `Animal` model (`animals/models.py`) + +| Field | Type | Required | Notes | +|-----------------------------|------------------|----------|--------------------------------------------------| +| `id` | UUIDField (PK) | auto | `uuid4`, non-editable | +| `full_name` | CharField(50) | yes | Unique per owner's animals (validated in form) | +| `short_description` | CharField(250) | no | Optional freetext | +| `long_description` | CharField(2500) | no | Optional freetext | +| `birthdate` | DateField | no | | +| `profile_image` | ImageField | default | Defaults to `profile_pics/pet-care.png` | +| `creation_date` | DateTimeField | auto | `auto_now_add`, non-editable | +| `owner` | FK → UserProfile | no (null)| `SET_NULL` on delete; `related_name="owner"` | +| `allowed_users` | M2M → UserProfile| — | Keepers; `related_name="keepers"` | +| `first_contact_vet` | CharField(250) | no | | +| `first_contact_medical_place`| CharField(250) | no | | +| `last_control_visit` | DateTimeField | no | | +| `next_visit_date` | DateField | no | | +| `dietary_restrictions` | CharField(2500) | no | | +| `species` | CharField(100) | no | Displayed alongside `breed` as "species / breed" | +| `breed` | CharField(100) | no | | +| `sex` | CharField(1) | no | Choices: `m`/`f` via `Sex(TextChoices)`; `get_sex_display()` → Male/Female | +| `sterilization` | BooleanField | default | `default=False`; shown as disabled checkbox in UI| + +**Optional field idiom** — all optional `CharField` / `DateField` use: +`default=None, blank=True, null=True`. + +**Boolean field idiom** — binary booleans (no "unknown" state) use: +`BooleanField(default=False)` without `null=True`. + +**`TextChoices` placement** — defined at module level, above the model class that uses them. + +#### `UserProfile` model (`users/models.py`) +Extends `auth.User` via OneToOne. Stores profile image, pinned animals (`M2M → Animal`). +Full field list: see `users/models.py`. + +#### `MedicalNote` models (`medical_notes/models/`) +Split into sub-models by note type. See `medical_notes/models/` for current field lists. +Core fields: `animal` (FK), `title`, `short_description`, `full_description`, `creation_date`, +`modify_date`, `start_event_date`, `end_event_date`, `type_of_event`. ### Consequences -##### _Placeholder_ - +- `Animal` fields are edited through the `Change*` pipeline documented in `CLAUDE.md` (Animals App — Conventions). +- New fields on `Animal` require a migration named `0NNN_add_.py` via `makemigrations animals --name`. +- Optional fields must **not** be displayed in the hero overview when empty — use `{% if animal.field %}` guards. ### Keywords -- data, -- database, -- models. - +- data, database, models, Animal, UserProfile, MedicalNote ### Links +- `CLAUDE.md` — Animals App Conventions (field editing pipeline, model idioms) +- ADR-08 — database technology choices (PostgreSQL / CouchDB / Redis) diff --git a/doc/11_adr_frontend_interactions.md b/doc/11_adr_frontend_interactions.md new file mode 100644 index 0000000..c5e36e3 --- /dev/null +++ b/doc/11_adr_frontend_interactions.md @@ -0,0 +1,52 @@ +## Frontend interaction layer: htmx + native dialog + +### Date: +`2026-05-31` + +### Status +Done + +### Context +The application uses PicoCSS server-rendered Django templates (ADR-06). +As the UI grew — tab-based animal profile, note creation from multiple surfaces — we needed +lightweight interactivity without adopting a full SPA or build pipeline. + +Requirements: +- Partial page updates (tab panels loaded on demand) +- Modal dialogs for note forms (avoid full-page navigation) +- No JS build step; vendor scripts served from `static/js/vendor/` +- Graceful degradation: every interactive element must still work with JS disabled + +Alternatives considered: +- **Alpine.js** — suitable for reactive bindings but no partial-rendering primitives. +- **Turbo (Hotwire)** — heavier Rails-centric model; Streams would require more backend work. +- **Full SPA (React/Vue)** — contradicts the monolith-first strategy in ADR-03 and adds build complexity. + +### Decision +- **htmx** (vendored, no CDN dependency) for partial rendering: + tab panel loading and modal form injection. +- **Native HTML `` element** styled by Pico CSS as the sole modal primitive. + One `` lives in `base.html` and is reused by all modal surfaces. +- Django views branch on `request.headers.get("HX-Request")`: + - GET → return `partials/modal_note_form.html` (no base layout) + - POST success → `HttpResponse(status=204)` with `HX-Redirect` header + - POST invalid → re-render the partial (htmx swaps errors back into `#modal-body`) +- `window.initNoteForm()` convention: per-form JS is exposed as a named global function + and called from `modal.js` after every htmx swap into `#modal-body`. + +### Consequences +- Every htmx trigger must also carry `href` for graceful degradation. +- Views that support modal loading need `get_template_names()` override and `form_action` / `legend` in context. +- `hiding_note_fields_in_form.js` (and any future per-form JS) must expose a stable `window.init*` function + so `modal.js` can re-initialise it after each swap. +- htmx and `modal.js` are loaded globally on every page via `base.html`; scripts are small and guarded. +- The `{% block tab_nav %}` extension point in `base.html` is the designated place for tab bars; + `{% block extra_css %}` and `{% block extra_js %}` remain for page-specific assets. + +### Keywords +- htmx, modal, dialog, partial template, tab, frontend, interaction + +### Links +*[2026-05-31]*\ +https://htmx.org/reference/#response_headers (HX-Redirect)\ +https://picocss.com/docs/modal (native dialog styling) diff --git a/src/ahc/apps/animals/migrations/0002_add_next_visit_date_and_dietary_restrictions.py b/src/ahc/apps/animals/migrations/0002_add_next_visit_date_and_dietary_restrictions.py new file mode 100644 index 0000000..87ac8f3 --- /dev/null +++ b/src/ahc/apps/animals/migrations/0002_add_next_visit_date_and_dietary_restrictions.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.5 on 2026-05-31 16:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("animals", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="animal", + name="dietary_restrictions", + field=models.CharField(blank=True, default=None, max_length=2500, null=True), + ), + migrations.AddField( + model_name="animal", + name="next_visit_date", + field=models.DateField(blank=True, default=None, null=True), + ), + ] diff --git a/src/ahc/apps/animals/migrations/0003_add_species_breed_sex_and_sterilization.py b/src/ahc/apps/animals/migrations/0003_add_species_breed_sex_and_sterilization.py new file mode 100644 index 0000000..10ef119 --- /dev/null +++ b/src/ahc/apps/animals/migrations/0003_add_species_breed_sex_and_sterilization.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.5 on 2026-05-31 21:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("animals", "0002_add_next_visit_date_and_dietary_restrictions"), + ] + + operations = [ + migrations.AddField( + model_name="animal", + name="breed", + field=models.CharField(blank=True, default=None, max_length=100, null=True), + ), + migrations.AddField( + model_name="animal", + name="sex", + field=models.CharField( + blank=True, choices=[("m", "Male"), ("f", "Female")], default=None, max_length=1, null=True + ), + ), + migrations.AddField( + model_name="animal", + name="species", + field=models.CharField(blank=True, default=None, max_length=100, null=True), + ), + migrations.AddField( + model_name="animal", + name="sterilization", + field=models.BooleanField(default=False), + ), + ] diff --git a/src/ahc/apps/animals/models.py b/src/ahc/apps/animals/models.py index 962f461..d1fcc0d 100644 --- a/src/ahc/apps/animals/models.py +++ b/src/ahc/apps/animals/models.py @@ -5,6 +5,11 @@ from ahc.apps.users.models import Profile as UserProfile +class Sex(models.TextChoices): + MALE = "m", "Male" + FEMALE = "f", "Female" + + class Animal(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) full_name = models.CharField(max_length=50, null=False, blank=False) @@ -26,3 +31,12 @@ class Animal(models.Model): # first_contact_medical_place = models.ForeignKey(Place_profile) last_control_visit = models.DateTimeField(null=True, default=None) + + next_visit_date = models.DateField(null=True, blank=True, default=None) + + dietary_restrictions = models.CharField(max_length=2500, null=True, blank=True, default=None) + + species = models.CharField(max_length=100, default=None, blank=True, null=True) + breed = models.CharField(max_length=100, default=None, blank=True, null=True) + sex = models.CharField(max_length=1, choices=Sex.choices, default=None, blank=True, null=True) + sterilization = models.BooleanField(default=False) diff --git a/src/ahc/apps/animals/services.py b/src/ahc/apps/animals/services.py index 3f7f188..7116b8e 100644 --- a/src/ahc/apps/animals/services.py +++ b/src/ahc/apps/animals/services.py @@ -66,3 +66,31 @@ def set_first_contact(animal: Animal, vet: str, place: str) -> None: animal.first_contact_vet = vet animal.first_contact_medical_place = place animal.save() + + +def set_next_visit(animal: Animal, next_visit_date) -> None: + """Set or clear the animal's next scheduled vet visit date.""" + animal.next_visit_date = next_visit_date + animal.save() + + +def set_dietary_restrictions(animal: Animal, restrictions: str) -> None: + """Update the animal's dietary restrictions / things to avoid.""" + animal.dietary_restrictions = restrictions + animal.save() + + +def set_animal_details( + animal: Animal, species: str | None, breed: str | None, sex: str | None, sterilization: bool +) -> None: + """Update the animal's species, breed, sex and sterilization status.""" + animal.species = species + animal.breed = breed + animal.sex = sex + animal.sterilization = sterilization + animal.save() + + +def remove_keeper(animal: Animal, keeper_id) -> None: + """Remove a keeper from the animal's allowed_users list by Profile PK.""" + animal.allowed_users.remove(keeper_id) 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

    +
    + {% csrf_token %} + {% include "partials/form_fields.html" %} + +
    +
    + Back to Settings +
    +{% 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

    +
    +
    {{ current_restrictions }}
    +
    +
    + {% else %} +

    No dietary restrictions set yet.

    + {% endif %} +
    + {% csrf_token %} + {% include "partials/form_fields.html" %} + +
    +
    + Back to Diet +
    +{% 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 %} +
    + {% csrf_token %} + {% include "partials/form_fields.html" %} + +
    +
    + Back to Vet & Visits +
    +{% endblock %} diff --git a/src/ahc/apps/animals/templates/animals/profile.html b/src/ahc/apps/animals/templates/animals/profile.html index 42cc1e9..5be9476 100644 --- a/src/ahc/apps/animals/templates/animals/profile.html +++ b/src/ahc/apps/animals/templates/animals/profile.html @@ -1,120 +1,74 @@ {% extends "homepage/base.html" %} {% load static %} {% load custom_timesince %} + {% block extra_css %} {% endblock %} + {% block extra_js %} {% endblock %} -{% block content %} -
    -
    - - Animal's profile picture - -
    -

    {{ animal.full_name }}

    - {% if animal.birthdate %} -

    Age: {{ animal.birthdate|years_and_months_since:now }}

    -

    Next birthday: {{ animal.birthdate|date:"d-m" }}-{{ now|date:"Y"}}

    - {% endif %} +{% block tab_nav %} + +{% endblock %} -

    Owner: {{ animal.owner }}

    - {% if animal.short_description %} -

    {{ animal.short_description }}

    - {% endif %} -
    -
    +{% block content %} +
    -
    -
    -

    Expand: additional description

    -
    - {{ animal.long_description|default:"No additional description." }} -
    -
    -
    -

    Expand: first contact details

    -
    -
    {{ animal.first_contact_vet }}
    -
    {{ animal.first_contact_medical_place }}
    -
    -
    -
    +
    + + Animal's profile picture + +
    +

    {{ animal.full_name }}

    -
    -

    Last records:

    -
    - - {% if recent_records %} -
      - {% for record in recent_records reversed %} -
    1. -
      - - {{ record.short_description }} -
      -
    2. - {% endfor %} -
    3. -
    + {% 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:

    -
    - + {% if animal.birthdate %} +

    Age: {{ animal.birthdate|years_and_months_since:now }}

    +

    Next birthday: {{ animal.birthdate|date:"d-m" }}-{{ now|date:"Y" }}

    + {% endif %} - {% if is_owner %} - - {% endif %} +

    Owner: {{ animal.owner }}

    + {% if animal.short_description %} +

    {{ animal.short_description }}

    + {% 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..1618010 --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_diet.html @@ -0,0 +1,47 @@ +{% load static %} +
    + +

    Diet

    + + {% if animal.dietary_restrictions %} +
    +

    Expand: dietary restrictions & things to avoid

    +
    +
    {{ animal.dietary_restrictions }}
    +
    +
    + {% endif %} + +
    + Add diet note + Manage all diets + {% if is_owner %} + Edit dietary restrictions + {% endif %} +
    + +
    + {% if diet_records %} +
    +
      + {% for record in diet_records %} +
    1. +
      + + {{ record.short_description }} +
      +
    2. + {% endfor %} +
    3. +
    +
    + {% else %} +

    No diet notes recorded yet.

    + {% 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..6c2917a --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_mainpage.html @@ -0,0 +1,30 @@ +{% load static %} +
    + +
    +
    +

    Expand: additional description

    +
    + {{ animal.long_description|default:"No additional description." }} +
    +
    +
    + +
    + + +
    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..802d7ba --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_medications.html @@ -0,0 +1,42 @@ +{% load static %} +
    + +

    Medications

    + + + +
    + {% if medication_records %} +
    +
      + {% for record in medication_records %} +
    1. +
      + + {{ record.short_description }} + {% if record.date_event_ended %} + – {{ record.date_event_ended|date:"Y-m-d" }} + {% endif %} +
      +
    2. + {% endfor %} +
    3. +
    +
    + {% else %} +

    No medication notes recorded yet.

    + {% 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..1b5c40e --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_notes.html @@ -0,0 +1,43 @@ +{% load static %} +
    + +

    Notes

    +
    + + {% if other_records %} +
      + {% for record in other_records %} +
    1. +
      + + {{ record.short_description }} + ({{ record.type_of_event }}) +
      + Edit + Delete +
      +
    2. + {% endfor %} +
    3. +
    + {% else %} +

    No notes recorded yet.

    + {% 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..3d70b2e --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_ownership.html @@ -0,0 +1,28 @@ +{% load static %} +
    + +

    Ownership

    +

    Current owner: {{ animal.owner }}

    + Change owner + +

    +

    Keepers

    + {% if keepers %} +
      + {% for keeper in keepers %} +
    • + {{ keeper }} +
      + {% csrf_token %} + +
      +
    • + {% 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 %} +
    + +

    Animal settings

    + +
    Profile
    + + +
    +
    Records
    + + +
    +
    + Danger zone +
    + Remove this animal from the files +
    + +
    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..308d97b --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_vet.html @@ -0,0 +1,57 @@ +{% load static %} +
    + +

    Veterinary contact

    +
    +

    Expand: first contact details

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

    Medical visit timeline

    +
    + + {% if vet_records %} +
      + {% for record in vet_records %} +
    1. +
      + + {{ record.short_description }} +
      +
    2. + {% endfor %} +
    3. +
    + {% else %} +

    No medical visits recorded yet.

    + {% endif %} +
    + +
    diff --git a/src/ahc/apps/animals/tests.py b/src/ahc/apps/animals/tests.py index 93d5b15..aab0722 100644 --- a/src/ahc/apps/animals/tests.py +++ b/src/ahc/apps/animals/tests.py @@ -316,3 +316,185 @@ 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_allowed_users(self): + from ahc.apps.animals.services import remove_keeper + + animal = MagicMock() + remove_keeper(animal, 99) + animal.allowed_users.remove.assert_called_once_with(99) + + 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.""" + from ahc.apps.animals.services import remove_keeper + + animal = MagicMock() + original_owner = MagicMock() + animal.owner = original_owner + 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 "<title>" 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..0f6e6de 100644 --- a/src/ahc/apps/animals/urls.py +++ b/src/ahc/apps/animals/urls.py @@ -10,8 +10,17 @@ path("<uuid:pk>/cnt/", animal_owner_views.ChangeFirstContactView.as_view(), name="animal_first_contact"), # TO change path("<uuid:pk>/btd/", animal_owner_views.ChangeBirthdayView.as_view(), name="animal_birthday"), path("<uuid:pk>/", animal_views.AnimalProfileDetailView.as_view(), name="animal_profile"), + path("<uuid:pk>/tab/<slug:slug>/", animal_views.AnimalTabView.as_view(), name="animal_tab"), path("<uuid:pk>/upload-image/", animal_owner_views.ImageUploadView.as_view(), name="upload_image"), path("<uuid:pk>/manage_keepers/", animal_owner_views.ManageKeepersView.as_view(), name="manage_keepers"), + path("<uuid:pk>/next-visit/", animal_owner_views.ChangeNextVisitView.as_view(), name="animal_next_visit"), + path( + "<uuid:pk>/dietary-restrictions/", + animal_owner_views.ChangeDietaryRestrictionsView.as_view(), + name="animal_dietary_restrictions", + ), + path("<uuid:pk>/details/", animal_owner_views.ChangeAnimalDetailsView.as_view(), name="animal_details"), + path("<uuid:pk>/keepers/<int:keeper_pk>/remove/", animal_owner_views.RemoveKeeperView.as_view(), name="remove_keeper"), 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 0e29669..af9b1f0 100644 --- a/src/ahc/apps/animals/utils_owner/forms.py +++ b/src/ahc/apps/animals/utils_owner/forms.py @@ -107,3 +107,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..f12eb63 100644 --- a/src/ahc/apps/animals/utils_owner/views.py +++ b/src/ahc/apps/animals/utils_owner/views.py @@ -1,7 +1,7 @@ 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 @@ -9,13 +9,20 @@ from ahc.apps.animals.services import ( add_keeper, process_profile_image, + remove_keeper, + set_animal_details, set_birthday, + set_dietary_restrictions, set_first_contact, + set_next_visit, transfer_ownership, ) from ahc.apps.animals.utils_owner.forms import ( + ChangeAnimalDetailsForm, ChangeBirthdayForm, + ChangeDietaryRestrictionsForm, ChangeFirstContactForm, + ChangeNextVisitForm, ChangeOwnerForm, ImageUploadForm, ManageKeepersForm, @@ -152,3 +159,78 @@ 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 allowed_users (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"})) diff --git a/src/ahc/apps/animals/views.py b/src/ahc/apps/animals/views.py index 5ecc377..87d73e6 100644 --- a/src/ahc/apps/animals/views.py +++ b/src/ahc/apps/animals/views.py @@ -1,5 +1,11 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +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.views.generic import TemplateView, View @@ -12,12 +18,91 @@ 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]] + + +def _build_mainpage(request, animal: Animal) -> dict[str, Any]: + return {} + + +def _build_vet(request, animal: Animal) -> dict[str, Any]: + from ahc.apps.medical_notes.selectors import timeline_for + + return { + "vet_records": timeline_for(animal, type_of_event="medical_visit").order_by("-date_creation"), + } + + +def _build_diet(request, animal: Animal) -> dict[str, Any]: + 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) -> dict[str, Any]: + from ahc.apps.medical_notes.selectors import medication_notes_for + + return {"medication_records": medication_notes_for(animal)} + + +def _build_notes(request, animal: Animal) -> dict[str, Any]: + from ahc.apps.medical_notes.selectors import other_records_for + + return {"other_records": other_records_for(animal)} + + +def _build_ownership(request, animal: Animal) -> dict[str, Any]: + return {"keepers": animal.allowed_users.all()} + + +def _build_settings(request, animal: Animal) -> dict[str, Any]: + return {} + + +TAB_REGISTRY: dict[str, Tab] = { + tab.slug: tab + for tab in [ + Tab("mainpage", "Overview", "animals/tabs/_mainpage.html", False, _build_mainpage), + Tab("vet", "Vet & Visits", "animals/tabs/_vet.html", False, _build_vet), + Tab("diet", "Diet", "animals/tabs/_diet.html", False, _build_diet), + Tab("medications", "Medications", "animals/tabs/_medications.html", False, _build_medications), + Tab("notes", "Notes", "animals/tabs/_notes.html", False, _build_notes), + 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) + return { + "now": timezone.now().date(), + "is_owner": owner, + "is_pinned": is_pinned(profile, animal), + # Non-owners do not see owner-only tabs in the nav bar. + "tabs": [t for t in TABS_LIST if not t.owner_only or owner], + } + + class CreateAnimalView(LoginRequiredMixin, FormView): template_name = "animals/create.html" form_class = AnimalRegisterForm @@ -30,18 +115,22 @@ 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) + context.update(_base_profile_context(self.request, self.object)) + 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)) return context def test_func(self): @@ -49,6 +138,49 @@ 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() + if not user_can_access_animal(self.request.user.profile, animal): + return False + tab = TAB_REGISTRY.get(self.kwargs.get("slug", "")) + if tab and tab.owner_only: + return is_animal_owner(self.request.user.profile, animal) + return True + + def get_template_names(self): + tab = self._get_tab() + if self.request.headers.get("HX-Request"): + return [tab.template] + return ["animals/profile.html"] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + tab = self._get_tab() + context.update(_base_profile_context(self.request, self.object)) + context["active_tab"] = tab.slug + context["active_partial"] = tab.template + context.update(tab.build(self.request, self.object)) + 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 bba9d54..fd8ce72 100644 --- a/src/ahc/apps/homepage/templates/homepage/base.html +++ b/src/ahc/apps/homepage/templates/homepage/base.html @@ -73,6 +73,22 @@ </div> </footer> +<dialog id="ahc-modal"> + <article> + <header> + <p><strong id="modal-title"></strong></p> + <button id="modal-close" aria-label="Close" rel="prev"></button> + </header> + <div id="modal-body"></div> + </article> +</dialog> + {% block extra_js %}{% endblock %} +<script defer src="{% static 'js/vendor/htmx.min.js' %}"></script> +<script defer src="{% static 'js/tabs.js' %}"></script> +<script defer src="{% static 'js/hiding_note_fields_in_form.js' %}"></script> +<script defer src="{% static 'js/tag_input.js' %}"></script> +<script defer src="{% static 'js/animal_select.js' %}"></script> +<script defer src="{% static 'js/modal.js' %}"></script> </body> </html> diff --git a/src/ahc/apps/medical_notes/forms/type_basic_note.py b/src/ahc/apps/medical_notes/forms/type_basic_note.py index 8544d56..867c092 100644 --- a/src/ahc/apps/medical_notes/forms/type_basic_note.py +++ b/src/ahc/apps/medical_notes/forms/type_basic_note.py @@ -13,7 +13,15 @@ class MedicalRecordForm(forms.ModelForm): ("other_user_note", "Other"), ) + MAX_ATTACHMENT_SIZE = 15 * 1024 * 1024 + ALLOWED_ATTACHMENT_TYPES = {"application/pdf", "image/jpeg", "image/png"} + type_of_event = forms.ChoiceField(choices=TYPES_OF_EVENTS, widget=forms.Select(attrs={"class": "custom-select"})) + attachment_file = forms.FileField( + required=False, + label="Attach file (optional)", + widget=forms.ClearableFileInput(attrs={"accept": "application/pdf,image/jpeg,image/png"}), + ) class Meta: model = MedicalRecord @@ -37,7 +45,7 @@ class Meta: "participants": forms.TextInput(attrs={"required": False}), "place": forms.TextInput(attrs={"required": False}), "note_tags": forms.TextInput(attrs={"required": False}), - "additional_animals": forms.SelectMultiple(attrs={"required": False}), + "additional_animals": forms.CheckboxSelectMultiple(attrs={"required": False}), } def __init__(self, *args, **kwargs): @@ -56,6 +64,16 @@ def __init__(self, *args, **kwargs): self.fields["additional_animals"].label = "Related animals" + def clean_attachment_file(self): + file = self.cleaned_data.get("attachment_file") + if not file: + return file + if file.size > self.MAX_ATTACHMENT_SIZE: + raise forms.ValidationError("Files above 15 MB are not allowed.") + if file.content_type not in self.ALLOWED_ATTACHMENT_TYPES: + raise forms.ValidationError("Only PDF, JPEG, and PNG files are allowed.") + return file + class MedicalRecordEditForm(MedicalRecordForm): def __init__(self, *args, **kwargs): diff --git a/src/ahc/apps/medical_notes/forms/type_feeding_notes.py b/src/ahc/apps/medical_notes/forms/type_feeding_notes.py index 31a8660..1299223 100644 --- a/src/ahc/apps/medical_notes/forms/type_feeding_notes.py +++ b/src/ahc/apps/medical_notes/forms/type_feeding_notes.py @@ -15,6 +15,7 @@ class Meta: "producer", "product_name", "dose_annotations", + "purchase_source", ] labels = { "real_start_date": "Actual start date of feeding", @@ -23,6 +24,7 @@ class Meta: "producer": "Producer", "product_name": "Product name", "dose_annotations": "Dosage details", + "purchase_source": "Where to buy", } category_choices = [("dry", "Dry"), ("wet", "Wet"), ("supplement", "Supplement")] @@ -37,6 +39,7 @@ class Meta: producer = forms.CharField(max_length=120, required=False) product_name = forms.CharField(max_length=80, required=True) dose_annotations = forms.CharField(max_length=250, required=False) + purchase_source = forms.CharField(max_length=250, required=False) class NotificationRecordForm(forms.ModelForm): diff --git a/src/ahc/apps/medical_notes/migrations/0015_add_feeding_note_purchase_source.py b/src/ahc/apps/medical_notes/migrations/0015_add_feeding_note_purchase_source.py new file mode 100644 index 0000000..7f9ac6d --- /dev/null +++ b/src/ahc/apps/medical_notes/migrations/0015_add_feeding_note_purchase_source.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.5 on 2026-05-31 16:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("medical_notes", "0014_alter_discordnotification_last_modification_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="feedingnote", + name="purchase_source", + field=models.CharField(blank=True, default=None, max_length=250, null=True), + ), + ] diff --git a/src/ahc/apps/medical_notes/models/type_feeding_notes.py b/src/ahc/apps/medical_notes/models/type_feeding_notes.py index 67a31c0..ae5be5d 100644 --- a/src/ahc/apps/medical_notes/models/type_feeding_notes.py +++ b/src/ahc/apps/medical_notes/models/type_feeding_notes.py @@ -22,6 +22,7 @@ class FeedingNote(models.Model): product_name = models.CharField(max_length=80) producer = models.CharField(max_length=120) dose_annotations = models.CharField(max_length=250) + purchase_source = models.CharField(max_length=250, null=True, blank=True, default=None) # create a view for the current diet and historical notes # create an app for the product catalog, build a registration of products, a purchases history and aggregation of costs diff --git a/src/ahc/apps/medical_notes/selectors.py b/src/ahc/apps/medical_notes/selectors.py index fa913b5..806ff6d 100644 --- a/src/ahc/apps/medical_notes/selectors.py +++ b/src/ahc/apps/medical_notes/selectors.py @@ -65,6 +65,32 @@ def notifications_for_mednote(mednote_uuid) -> QuerySet: return EmailNotification.objects.filter(related_note__in=feednotes).order_by("-last_modification") +def medication_notes_for(animal) -> QuerySet[MedicalRecord]: + """Return MedicalRecords of type medicament_note for an animal. + + Used by the Medications tab. Ordered newest first. + """ + return ( + MedicalRecord.objects.filter(animal=animal, type_of_event="medicament_note") + .prefetch_related("attachments") + .order_by("-date_creation") + ) + + +def other_records_for(animal) -> QuerySet[MedicalRecord]: + """Return MedicalRecords for an animal, excluding types shown on specialised tabs. + + Excludes medical_visit (Vet), diet_note (Diet), and medicament_note (Medications). + Results are prefetch_related for attachments and ordered newest first. + """ + return ( + MedicalRecord.objects.filter(animal=animal) + .exclude(type_of_event__in=["medical_visit", "diet_note", "medicament_note"]) + .prefetch_related("attachments") + .order_by("-date_creation") + ) + + def is_note_author(profile, note: MedicalRecord) -> bool: """Return True if the profile is the author of the note.""" return note.author == profile diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/create.html b/src/ahc/apps/medical_notes/templates/medical_notes/create.html index c1a8883..698014f 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/create.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/create.html @@ -1,15 +1,13 @@ {% extends "homepage/base.html" %} {% load static %} {% block extra_js %} - {% if form_name == 'MedicalRecordForm' %} - <script defer src="{% static 'js/hiding_note_fields_in_form.js' %}"></script> - {% elif form_name == 'BiometricRecordForm' %} + {% if form_name == 'BiometricRecordForm' %} <script defer src="{% static 'js/hiding_note_fields_in_measurement_form.js' %}"></script> {% endif %} {% endblock %} {% block content %} <div class="content-section"> - <form method="POST"> + <form method="POST" enctype="multipart/form-data"> {% csrf_token %} <fieldset> <legend>Register a new note related with:</legend> diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/edit.html b/src/ahc/apps/medical_notes/templates/medical_notes/edit.html index 3a4ae6b..f7b6b67 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/edit.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/edit.html @@ -1,11 +1,9 @@ {% extends "homepage/base.html" %} {% load static %} -{% block extra_js %} - <script defer src="{% static 'js/hiding_note_fields_in_form.js' %}"></script> -{% endblock %} +{% block extra_js %}{% endblock %} {% block content %} <div class="content-section"> - <form method="POST"> + <form method="POST" enctype="multipart/form-data"> {% csrf_token %} <fieldset> <legend>Edit a note related with {{ note.animal.full_name }}:</legend> 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 f41118f..c46bba0 100644 --- a/src/ahc/apps/medical_notes/views/type_basic_note.py +++ b/src/ahc/apps/medical_notes/views/type_basic_note.py @@ -41,6 +41,11 @@ class CreateNoteFormView(LoginRequiredMixin, AnimalDirectAccessRequiredMixin, Fo template_name = "medical_notes/create.html" form_class = MedicalRecordForm + def get_template_names(self): + if self.request.headers.get("HX-Request"): + return ["partials/modal_note_form.html"] + return [self.template_name] + def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["animal_choices"] = animal_choices_for(self.request.user.profile, exclude_id=self.kwargs.get("pk")) @@ -50,14 +55,38 @@ def get_form_kwargs(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["form_name"] = str(self.form_class.__name__) + context["form_action"] = self.request.get_full_path() + legend_map = { + "medical_visit": "Add vet visit", + "diet_note": "Diet note", + "biometric_record": "Biometric record", + "medicament_note": "Medicament note", + "fast_note": "Quick note", + } + context["legend"] = legend_map.get(self.request.GET.get("type_of_event", ""), "New note") return context def form_valid(self, form): animal_id = self.kwargs.get("pk") animal = get_object_or_404(AnimalProfile, id=animal_id) note = create_note(self.request.user.profile, animal, form) + uploaded_file = self.request.FILES.get("attachment_file") + if uploaded_file: + try: + upload_attachment( + medical_record=note, + attachment_instance=MedicalRecordAttachment(), + uploaded_file=uploaded_file, + ) + except AttachmentLimitExceeded as exc: + messages.warning(self.request, f"Note saved but attachment upload failed: {exc}") url_name, kwargs = next_route_for(note, animal_id) - return redirect(reverse(url_name, kwargs=kwargs)) + redirect_url = reverse(url_name, kwargs=kwargs) + if self.request.headers.get("HX-Request"): + response = HttpResponse(status=204) + response["HX-Redirect"] = redirect_url + return response + return redirect(redirect_url) def get_success_url(self): animal_id = self.kwargs.get("pk") @@ -133,6 +162,17 @@ class EditNoteView(LoginRequiredMixin, NoteAuthorRequiredMixin, UpdateView): template_name = "medical_notes/edit.html" context_object_name = "note" + def get_template_names(self): + if self.request.headers.get("HX-Request"): + return ["partials/modal_note_form.html"] + return [self.template_name] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["form_action"] = self.request.get_full_path() + context["legend"] = f"Edit note for {self.object.animal.full_name}" + return context + def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["animal_choices"] = animal_choices_for(self.request.user.profile) @@ -145,6 +185,20 @@ def get_form_kwargs(self): def form_valid(self, form): note = get_object_or_404(MedicalRecord, id=self.kwargs.get("pk")) update_note(note, form) + uploaded_file = self.request.FILES.get("attachment_file") + if uploaded_file: + try: + upload_attachment( + medical_record=note, + attachment_instance=MedicalRecordAttachment(), + uploaded_file=uploaded_file, + ) + except AttachmentLimitExceeded as exc: + messages.warning(self.request, f"Note saved but attachment upload failed: {exc}") + if self.request.headers.get("HX-Request"): + response = HttpResponse(status=204) + response["HX-Redirect"] = self.get_success_url() + return response return super().form_valid(form) def get_success_url(self): diff --git a/static/css/custom_pico.css b/static/css/custom_pico.css index 7f467f8..ae4bef8 100644 --- a/static/css/custom_pico.css +++ b/static/css/custom_pico.css @@ -41,7 +41,7 @@ main { display: flex; gap: 0; margin: 0; - padding: 0 var(--pico-spacing); + padding: 0; list-style: none; overflow-x: auto; } @@ -70,6 +70,24 @@ main { border-bottom-color: var(--pico-primary); } +#tab-panels { + padding-top: 1rem; +} + +.keeper-list { + list-style: none; + padding: 0; + margin: 0 0 1rem; +} + +.keeper-list__item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; + border-bottom: 1px solid var(--pico-muted-border-color); +} + h1, h2, h3, h4, h5, h6 { font-family: var(--ahc-font-display); font-weight: 700; @@ -206,3 +224,134 @@ h1, h2, h3, h4, h5, h6 { text-overflow: ellipsis; white-space: nowrap; } + +/* additional_animals: CheckboxSelectMultiple rendered as pill toggles */ +#field_additional_animals ul { + list-style: none; + padding: 0; + margin: 0.25rem 0 0; + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +#field_additional_animals li { + display: block; +} + +#field_additional_animals input[type="checkbox"] { + display: none; +} + +#field_additional_animals li label { + display: inline-flex; + align-items: center; + padding: 0.3rem 0.75rem; + border: 1px solid var(--pico-muted-border-color); + border-radius: 20px; + font-size: 0.875rem; + font-weight: normal; + cursor: pointer; + user-select: none; + margin-bottom: 0; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +#field_additional_animals li label:hover { + border-color: var(--pico-primary); +} + +#field_additional_animals li:has(input:checked) label, +#field_additional_animals label:has(input:checked) { + background: var(--pico-primary); + border-color: var(--pico-primary); + color: var(--pico-primary-inverse, #000); +} + +/* note_tags: JS-enhanced pill tag input */ +.tag-input-container { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.5rem; + border: var(--pico-border-width) solid var(--pico-muted-border-color); + border-radius: var(--pico-border-radius); + background: var(--pico-form-element-background-color); + min-height: 2.5rem; + cursor: text; + transition: border-color 0.15s; +} + +.tag-input-container:focus-within { + border-color: var(--pico-primary); +} + +.tag-pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.375rem 0.2rem 0.625rem; + background: var(--pico-primary); + color: var(--pico-primary-inverse, #000); + border-radius: 20px; + font-size: 0.8125rem; + font-family: var(--ahc-font-display); + font-weight: 600; +} + +.tag-pill__remove { + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: inherit; + opacity: 0.6; + font-size: 1rem; + line-height: 1; + min-height: unset; + display: flex; + align-items: center; +} + +.tag-pill__remove:hover { + opacity: 1; +} + +.tag-text-input, +.tag-text-input:focus, +.tag-text-input:focus-visible { + border: none; + outline: none; + box-shadow: none; + background: transparent; + flex: 1; + min-width: 80px; + padding: 0; + margin: 0; + font-size: 0.875rem; + color: var(--pico-color); + height: auto; +} + +/* Selected-animals pill bar (above the checkbox list) */ +.animal-select-pills { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-bottom: 0.5rem; +} + +/* Modal dialog */ +#ahc-modal article { + max-width: 560px; + width: 100%; +} + +.modal-form-actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + justify-content: flex-end; +} diff --git a/static/js/animal_select.js b/static/js/animal_select.js new file mode 100644 index 0000000..d9aed7e --- /dev/null +++ b/static/js/animal_select.js @@ -0,0 +1,73 @@ +// Animal multi-select: shows checked animals as dismissible tag-pills above the checkbox list. +// Reuses .tag-pill / .tag-pill__remove CSS from the tag input component. +// Exposed as window.initAnimalSelect so modal.js can re-run it after htmx swaps. +// Re-entrant safe: removes any existing pill bar before building a new one. + +(function () { + "use strict"; + + window.initAnimalSelect = function initAnimalSelect() { + var wrapper = document.getElementById("field_additional_animals"); + if (!wrapper) return; + + // Remove any pill bar from a previous initialisation (re-entrant safe). + var existing = wrapper.querySelector(".animal-select-pills"); + if (existing) existing.remove(); + + var checkboxes = Array.from(wrapper.querySelectorAll("input[type='checkbox']")); + if (!checkboxes.length) return; + + var pillBar = document.createElement("div"); + pillBar.className = "animal-select-pills"; + + var fieldLabel = wrapper.querySelector("label[for]"); + var anchor = fieldLabel ? fieldLabel.nextElementSibling : null; + if (anchor) { + wrapper.insertBefore(pillBar, anchor); + } else { + wrapper.appendChild(pillBar); + } + + function labelText(cb) { + var lbl = cb.closest("label"); + if (lbl) return lbl.textContent.trim(); + var explicit = document.querySelector("label[for='" + cb.id + "']"); + return explicit ? explicit.textContent.trim() : cb.value; + } + + function render() { + pillBar.innerHTML = ""; + checkboxes.forEach(function (cb) { + if (!cb.checked) return; + + var pill = document.createElement("span"); + pill.className = "tag-pill"; + + var text = document.createElement("span"); + text.textContent = labelText(cb); + + var btn = document.createElement("button"); + btn.type = "button"; + btn.className = "tag-pill__remove"; + btn.setAttribute("aria-label", "Remove " + text.textContent); + btn.textContent = "×"; + btn.addEventListener("click", function () { + cb.checked = false; + render(); + }); + + pill.appendChild(text); + pill.appendChild(btn); + pillBar.appendChild(pill); + }); + } + + checkboxes.forEach(function (cb) { + cb.addEventListener("change", render); + }); + + render(); + }; + + document.addEventListener("DOMContentLoaded", window.initAnimalSelect); +}()); diff --git a/static/js/expanding_sections.js b/static/js/expanding_sections.js index 09d518b..f0e6a99 100644 --- a/static/js/expanding_sections.js +++ b/static/js/expanding_sections.js @@ -1,7 +1,12 @@ -document.addEventListener("DOMContentLoaded", function() { - const sections = document.querySelectorAll(".section"); +// Accordion sections: toggle .section-content visibility on header click. +// initExpandingSections() is called on DOMContentLoaded and after htmx swaps. + +function initExpandingSections() { + document.querySelectorAll(".section").forEach(function (section) { + // Skip sections that were already initialised. + if (section.dataset.expandingInit) return; + section.dataset.expandingInit = "1"; - sections.forEach(section => { const header = section.querySelector(".section-header"); const content = section.querySelector(".section-content"); @@ -9,18 +14,20 @@ document.addEventListener("DOMContentLoaded", function() { header.setAttribute("aria-expanded", "false"); - const toggle = () => { + const toggle = function () { const expanded = content.style.display !== "none" && content.style.display !== ""; content.style.display = expanded ? "none" : "block"; header.setAttribute("aria-expanded", String(!expanded)); }; header.addEventListener("click", toggle); - header.addEventListener("keydown", (event) => { + header.addEventListener("keydown", function (event) { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggle(); } }); }); -}); +} + +document.addEventListener("DOMContentLoaded", initExpandingSections); diff --git a/static/js/hiding_note_fields_in_form.js b/static/js/hiding_note_fields_in_form.js index 7648824..95997ec 100644 --- a/static/js/hiding_note_fields_in_form.js +++ b/static/js/hiding_note_fields_in_form.js @@ -1,22 +1,28 @@ -document.addEventListener('DOMContentLoaded', function() { - const typeOfEventField = document.getElementById('id_type_of_event'); - if (!typeOfEventField) return; +// Conditional field visibility for MedicalRecordForm. +// Exposed as window.initNoteForm so modal.js can re-run it after htmx swaps. - const participantsField = document.getElementById('field_participants'); - const placeField = document.getElementById('field_place'); - const fulldescriptionField = document.getElementById('field_full_description'); - const eventstartedField = document.getElementById('field_date_event_started'); - const eventendedField = document.getElementById('field_date_event_ended'); +(function () { + "use strict"; - const allOptional = [participantsField, placeField, fulldescriptionField, eventstartedField, eventendedField]; + function handleTypeOfEventChange() { + var typeOfEventField = document.getElementById('id_type_of_event'); + if (!typeOfEventField) return; - function show(...fields) { - allOptional.forEach(f => { if (f) f.style.display = 'none'; }); - fields.forEach(f => { if (f) f.style.display = 'block'; }); - } + var participantsField = document.getElementById('field_participants'); + var placeField = document.getElementById('field_place'); + var fulldescriptionField = document.getElementById('field_full_description'); + var eventstartedField = document.getElementById('field_date_event_started'); + var eventendedField = document.getElementById('field_date_event_ended'); - function handleTypeOfEventChange() { - const value = typeOfEventField.value; + var allOptional = [participantsField, placeField, fulldescriptionField, eventstartedField, eventendedField]; + + function show() { + var fields = Array.from(arguments); + allOptional.forEach(function (f) { if (f) f.style.display = 'none'; }); + fields.forEach(function (f) { if (f) f.style.display = 'block'; }); + } + + var value = typeOfEventField.value; if (value === 'fast_note') { show(); } else if (value === 'medical_visit') { @@ -30,6 +36,12 @@ document.addEventListener('DOMContentLoaded', function() { } } - handleTypeOfEventChange(); - typeOfEventField.addEventListener('change', handleTypeOfEventChange); -}); + window.initNoteForm = function initNoteForm() { + var typeOfEventField = document.getElementById('id_type_of_event'); + if (!typeOfEventField) return; + handleTypeOfEventChange(); + typeOfEventField.addEventListener('change', handleTypeOfEventChange); + }; + + document.addEventListener('DOMContentLoaded', window.initNoteForm); +}()); diff --git a/static/js/modal.js b/static/js/modal.js new file mode 100644 index 0000000..ee51b3e --- /dev/null +++ b/static/js/modal.js @@ -0,0 +1,45 @@ +// Modal: open/close <dialog id="ahc-modal"> driven by htmx swaps. +// Loaded globally from base.html; all handlers are external (CSP: no inline JS). + +(function () { + "use strict"; + + var modal = document.getElementById("ahc-modal"); + if (!modal) return; + + var modalTitle = document.getElementById("modal-title"); + + // Populate modal title from the triggering element before the htmx request fires. + document.addEventListener("htmx:beforeRequest", function (event) { + if (event.detail.target.id !== "modal-body") return; + var elt = event.detail.elt; + if (elt && modalTitle && elt.dataset.modalTitle) { + modalTitle.textContent = elt.dataset.modalTitle; + } + }); + + // Show modal and re-init per-form JS after htmx injects content. + document.addEventListener("htmx:afterSwap", function (event) { + if (event.detail.target.id !== "modal-body") return; + if (!modal.open) modal.showModal(); + if (typeof window.initNoteForm === "function") window.initNoteForm(); + if (typeof window.initTagInput === "function") window.initTagInput(); + if (typeof window.initAnimalSelect === "function") window.initAnimalSelect(); + }); + + // Close on backdrop click (clicking the <dialog> element itself, not the article). + modal.addEventListener("click", function (event) { + if (event.target === modal) modal.close(); + }); + + // Close via [data-close-modal] (delegated — survives htmx content replacement). + document.addEventListener("click", function (event) { + if (event.target.closest("[data-close-modal]")) modal.close(); + }); + + // Wire Pico's header close button. + var closeBtn = document.getElementById("modal-close"); + if (closeBtn) { + closeBtn.addEventListener("click", function () { modal.close(); }); + } +}()); diff --git a/static/js/pin_animal.js b/static/js/pin_animal.js index 9cb68d3..67e34d4 100644 --- a/static/js/pin_animal.js +++ b/static/js/pin_animal.js @@ -1,46 +1,55 @@ -const link = document.getElementById('togglePinnedButton'); - -if (link) { - link.addEventListener('click', async function(event) { - event.preventDefault(); - - const animalId = link.dataset.animalId; - const action = link.dataset.action; - const newAction = (action === 'add') ? 'remove' : 'add'; - - try { - const body = new URLSearchParams({animal_id: animalId, action: action}); - const response = await fetch(link.href, { - method: 'POST', - headers: { - 'X-CSRFToken': getCookie('csrftoken'), - }, - body: body, - }); - - if (!response.ok) { - throw new Error('Request failed: ' + response.statusText); - } - - link.dataset.action = newAction; - link.innerText = (newAction === 'add') ? 'Add to Pinned' : 'Remove from Pinned'; - } catch (error) { - console.error('Error:', error); - } - }); +// Pin/unpin button: async POST to the pinned_animals endpoint. +// initPinButton() is called on DOMContentLoaded and after htmx swaps. +// Uses cloneNode to cleanly remove any stale event listeners before rebinding. + +function initPinButton() { + const existing = document.getElementById("togglePinnedButton"); + if (!existing) return; + + // Replace with a clone to drop any previously attached listeners. + const link = existing.cloneNode(true); + existing.parentNode.replaceChild(link, existing); + + link.addEventListener("click", async function (event) { + event.preventDefault(); + + const animalId = link.dataset.animalId; + const action = link.dataset.action; + const newAction = action === "add" ? "remove" : "add"; + + try { + const body = new URLSearchParams({ animal_id: animalId, action: action }); + const response = await fetch(link.href, { + method: "POST", + headers: { "X-CSRFToken": getCookie("csrftoken") }, + body: body, + }); + + if (!response.ok) { + throw new Error("Request failed: " + response.statusText); + } + + link.dataset.action = newAction; + link.innerText = newAction === "add" ? "Add to Pinned" : "Remove from Pinned"; + } catch (error) { + console.error("Error:", error); + } + }); } function getCookie(name) { - let cookieValue = null; - if (document.cookie && document.cookie !== '') { - const cookies = document.cookie.split(';'); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } + let cookieValue = null; + if (document.cookie && document.cookie !== "") { + const cookies = document.cookie.split(";"); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === name + "=") { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } } - } - return cookieValue; + return cookieValue; } + +document.addEventListener("DOMContentLoaded", initPinButton); diff --git a/static/js/tabs.js b/static/js/tabs.js new file mode 100644 index 0000000..b0b70f8 --- /dev/null +++ b/static/js/tabs.js @@ -0,0 +1,45 @@ +// Tab navigation: active-state management and per-panel script re-initialisation. +// Loaded globally from base.html; all handlers are external (CSP: no inline JS). + +(function () { + "use strict"; + + function getActiveSlugFromPath() { + // Match paths like /animals/<uuid>/tab/<slug>/ + const match = window.location.pathname.match(/\/tab\/([^/]+)\/?$/); + return match ? match[1] : null; + } + + function syncActiveTab(slug) { + const nav = document.querySelector(".tab-nav"); + if (!nav || !slug) return; + nav.querySelectorAll("[role='tab']").forEach(function (link) { + link.classList.toggle("active", link.dataset.tab === slug); + }); + } + + // Delegate tab clicks: update active class immediately without waiting for swap. + document.addEventListener("click", function (event) { + const link = event.target.closest(".tab-nav [role='tab']"); + if (!link) return; + syncActiveTab(link.dataset.tab); + }); + + // Resync active tab when navigating back/forward. + window.addEventListener("popstate", function () { + syncActiveTab(getActiveSlugFromPath()); + }); + + // After htmx injects a new tab panel, signal each per-page script to re-bind. + document.addEventListener("htmx:afterSwap", function () { + if (typeof initExpandingSections === "function") { + initExpandingSections(); + } + if (typeof initTimeline === "function") { + initTimeline(); + } + if (typeof initPinButton === "function") { + initPinButton(); + } + }); +}()); diff --git a/static/js/tag_input.js b/static/js/tag_input.js new file mode 100644 index 0000000..30f0cea --- /dev/null +++ b/static/js/tag_input.js @@ -0,0 +1,113 @@ +// Tag pill input: enhances #id_note_tags (plain text) into an interactive pill UI. +// The original input is hidden and kept as the source of truth (comma-separated string). +// Exposed as window.initTagInput so modal.js can re-run it after htmx swaps. +// Re-entrant safe: removes any existing container before building a new one. + +(function () { + "use strict"; + + function buildTagInput(field) { + var parent = field.parentNode; + + // Remove any container from a previous initialisation (re-entrant safe). + var existing = parent.querySelector(".tag-input-container"); + if (existing) existing.remove(); + + field.style.display = "none"; + + var container = document.createElement("div"); + container.className = "tag-input-container"; + parent.insertBefore(container, field.nextSibling); + + var textInput = document.createElement("input"); + textInput.type = "text"; + textInput.className = "tag-text-input"; + textInput.placeholder = "Add tag…"; + textInput.setAttribute("aria-label", "Add tag"); + container.appendChild(textInput); + + function getTags() { + return field.value + .split(",") + .map(function (t) { return t.trim(); }) + .filter(Boolean); + } + + function setTags(tags) { + field.value = tags.join(", "); + } + + function renderPills() { + container.querySelectorAll(".tag-pill").forEach(function (p) { p.remove(); }); + getTags().forEach(function (tag, index) { + var pill = document.createElement("span"); + pill.className = "tag-pill"; + + var text = document.createElement("span"); + text.textContent = tag; + + var btn = document.createElement("button"); + btn.type = "button"; + btn.className = "tag-pill__remove"; + btn.setAttribute("aria-label", "Remove " + tag); + btn.setAttribute("data-tag-index", index); + btn.textContent = "×"; + btn.addEventListener("click", function () { + var i = parseInt(this.getAttribute("data-tag-index"), 10); + var tags = getTags(); + if (i >= 0 && i < tags.length) { + tags.splice(i, 1); + setTags(tags); + renderPills(); + } + }); + + pill.appendChild(text); + pill.appendChild(btn); + container.insertBefore(pill, textInput); + }); + } + + function addTag(raw) { + var tag = raw.trim().replace(/,/g, ""); + if (!tag) return; + var tags = getTags(); + if (tags.indexOf(tag) === -1) { + tags.push(tag); + setTags(tags); + renderPills(); + } + textInput.value = ""; + } + + textInput.addEventListener("keydown", function (event) { + if (event.key === "," || event.key === "Enter") { + event.preventDefault(); + addTag(textInput.value); + } else if (event.key === "Backspace" && !textInput.value) { + var tags = getTags(); + if (tags.length) { + tags.pop(); + setTags(tags); + renderPills(); + } + } + }); + + textInput.addEventListener("blur", function () { + if (textInput.value.trim()) addTag(textInput.value); + }); + + container.addEventListener("click", function () { textInput.focus(); }); + + renderPills(); + } + + window.initTagInput = function initTagInput() { + var field = document.getElementById("id_note_tags"); + if (!field) return; + buildTagInput(field); + }; + + document.addEventListener("DOMContentLoaded", window.initTagInput); +}()); diff --git a/static/js/timeline.js b/static/js/timeline.js index 44e98e0..ea10610 100644 --- a/static/js/timeline.js +++ b/static/js/timeline.js @@ -1,25 +1,24 @@ -// VARIABLES -const elH = document.querySelectorAll(".timeline li > div"); +// Timeline layout: equalise heights of list-item divs so the connector line aligns. +// initTimeline() is called on window load and after htmx swaps. -// START -window.addEventListener("load", init); - -function init() { - setEqualHeights(elH); +function initTimeline() { + const elements = document.querySelectorAll(".timeline li > div"); + if (elements.length > 0) { + setEqualHeights(elements); + } } -// SET EQUAL HEIGHTS function setEqualHeights(el) { - let counter = 0; - for (let i = 0; i < el.length; i++) { - const singleHeight = el[i].offsetHeight; - - if (counter < singleHeight) { - counter = singleHeight; + let counter = 0; + for (let i = 0; i < el.length; i++) { + const singleHeight = el[i].offsetHeight; + if (counter < singleHeight) { + counter = singleHeight; + } + } + for (let i = 0; i < el.length; i++) { + el[i].style.height = counter + "px"; } - } - - for (let i = 0; i < el.length; i++) { - el[i].style.height = `${counter}px`; - } } + +window.addEventListener("load", initTimeline); diff --git a/static/js/vendor/htmx.min.js b/static/js/vendor/htmx.min.js new file mode 100644 index 0000000..59937d7 --- /dev/null +++ b/static/js/vendor/htmx.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e<t.length;e++){n.push(t[e])}}return n}function se(t,n){if(t){for(let e=0;e<t.length;e++){n(t[e])}}}function X(e){const t=e.getBoundingClientRect();const n=t.top;const r=t.bottom;return n<window.innerHeight&&r>=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e<r.length;e++){const l=r[e];if(l===","&&t===0){o.push(r.substring(n,e));n=e+1;continue}if(l==="<"){t++}else if(l==="/"&&e<r.length-1&&r[e+1]===">"){t--}}if(n<r.length){o.push(r.substring(n))}}const i=[];const s=[];while(o.length>0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e<r.length;e++){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_PRECEDING){return o}}};var me=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=r.length-1;e>=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e<n.length;e++){if(t===n[e]){return true}}return false}function Oe(t,n){se(t.attributes,function(e){if(!n.hasAttribute(e.name)&&Ce(e.name)){t.removeAttribute(e.name)}});se(n.attributes,function(e){if(Ce(e.name)){t.setAttribute(e.name,e.value)}})}function Re(t,e){const n=Un(e);for(let e=0;e<n.length;e++){const r=n[e];try{if(r.isInlineSwap(t)){return true}}catch(e){O(e)}}return t==="outerHTML"}function He(e,o,i,t){t=t||ne();let n="#"+ee(o,"id");let s="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","<div id='--htmx-preserve-pantry--'></div>");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n<e.length){t=(t<<5)-t+e.charCodeAt(n++)|0}return t}function Pe(t){let n=0;if(t.attributes){for(let e=0;e<t.attributes.length;e++){const r=t.attributes[e];if(r.value){n=Ie(r.name,n);n=Ie(r.value,n)}}}return n}function ke(t){const n=ie(t);if(n.onHandlers){for(let e=0;e<n.onHandlers.length;e++){const r=n.onHandlers[e];be(t,r.event,r.listener)}delete n.onHandlers}}function De(e){const t=ie(e);if(t.timeout){clearTimeout(t.timeout)}if(t.listenerInfos){se(t.listenerInfos,function(e){if(e.on){be(e.on,e.trigger,e.listener)}})}ke(e);se(Object.keys(t),function(e){if(e!=="firstInitCompleted")delete t[e]})}function b(e){he(e,"htmx:beforeCleanupElement");De(e);if(e.children){se(e.children,function(e){b(e)})}}function Me(t,e,n){if(t instanceof Element&&t.tagName==="BODY"){return Ve(t,e,n)}let r;const o=t.previousSibling;const i=c(t);if(!i){return}a(i,t,e,n);if(o==null){r=i.firstChild}else{r=o.nextSibling}n.elts=n.elts.filter(function(e){return e!==t});while(r&&r!==t){if(r instanceof Element){n.elts.push(r)}r=r.nextSibling}b(t);if(t instanceof Element){t.remove()}else{t.parentNode.removeChild(t)}}function Xe(e,t,n){return a(e,e.firstChild,t,n)}function Fe(e,t,n){return a(c(e),e,t,n)}function Be(e,t,n){return a(e,null,t,n)}function Ue(e,t,n){return a(c(e),e.nextSibling,t,n)}function je(e){b(e);const t=c(e);if(t){return t.removeChild(e)}}function Ve(e,t,n){const r=e.firstChild;a(e,r,t,n);if(r){while(r.nextSibling){b(r.nextSibling);e.removeChild(r.nextSibling)}b(r);e.removeChild(r)}}function _e(t,e,n,r,o){switch(t){case"none":return;case"outerHTML":Me(n,r,o);return;case"afterbegin":Xe(n,r,o);return;case"beforebegin":Fe(n,r,o);return;case"beforeend":Be(n,r,o);return;case"afterend":Ue(n,r,o);return;case"delete":je(n);return;default:var i=Un(e);for(let e=0;e<i.length;e++){const s=i[e];try{const l=s.handleSwap(t,n,r,o);if(l){if(Array.isArray(l)){for(let e=0;e<l.length;e++){const c=l[e];if(c.nodeType!==Node.TEXT_NODE&&c.nodeType!==Node.COMMENT_NODE){o.tasks.push(Ae(c))}}}return}}catch(e){O(e)}}if(t==="innerHTML"){Ve(n,r,o)}else{_e(Q.config.defaultSwapStyle,e,n,r,o)}}}function ze(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");se(t,function(e){if(Q.config.allowNestedOobSwaps||e.parentElement===null){const t=te(e,"hx-swap-oob");if(t!=null){He(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t<u.length;t++){const a=u[t].split(":",2);let e=a[0].trim();if(e.indexOf("#")===0){e=e.substring(1)}const f=a[1]||"true";const h=n.querySelector("#"+e);if(h){He(f,h,l,i)}}}ze(n,l,i);se(x(n,"template"),function(e){if(e.content&&ze(e.content,l,i)){e.remove()}});if(o.select){const d=ne().createDocumentFragment();se(n.querySelectorAll(o.select),function(e){d.appendChild(e)});n=d}qe(n);_e(r.swapStyle,o.contextElement,e,n,l);Te()}if(s.elt&&!le(s.elt)&&ee(s.elt,"id")){const g=document.getElementById(ee(s.elt,"id"));const p={preventScroll:r.focusScroll!==undefined?!r.focusScroll:!Q.config.defaultFocusScroll};if(g){if(s.start&&g.setSelectionRange){try{g.setSelectionRange(s.start,s.end)}catch(e){}}g.focus(p)}}e.classList.remove(Q.config.swappingClass);se(l.elts,function(e){if(e.classList){e.classList.add(Q.config.settlingClass)}he(e,"htmx:afterSwap",o.eventInfo)});if(o.afterSwapCallback){o.afterSwapCallback()}if(!r.ignoreTitle){kn(l.title)}const c=function(){se(l.tasks,function(e){e.call()});se(l.elts,function(e){if(e.classList){e.classList.remove(Q.config.settlingClass)}he(e,"htmx:afterSettle",o.eventInfo)});if(o.anchor){const e=ue(y("#"+o.anchor));if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}yn(l.elts,r);if(o.afterSettleCallback){o.afterSettleCallback()}};if(r.settleDelay>0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e<s.length;e++){he(n,s[e].trim(),[])}}}const Ke=/\s/;const v=/[\s,]/;const Ge=/[_$a-zA-Z]/;const We=/[_$a-zA-Z0-9]/;const Ze=['"',"'","/"];const w=/[^\s]/;const Ye=/[{(]/;const Qe=/[})]/;function et(e){const t=[];let n=0;while(n<e.length){if(Ge.exec(e.charAt(n))){var r=n;while(We.exec(e.charAt(n+1))){n++}t.push(e.substring(r,n+1))}else if(Ze.indexOf(e.charAt(n))!==-1){const o=e.charAt(n);var r=n;n++;while(n<e.length&&e.charAt(n)!==o){if(e.charAt(n)==="\\"){n++}n++}t.push(e.substring(r,n+1))}else{const i=e.charAt(n);t.push(i)}n++}return t}function tt(e,t,n){return Ge.exec(e.charAt(0))&&e!=="true"&&e!=="false"&&e!=="this"&&e!==n&&t!=="."}function nt(r,o,i){if(o[0]==="["){o.shift();let e=1;let t=" return (function("+i+"){ return (";let n=null;while(o.length>0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e<t.length;e++){const n=t[e];if(n.isIntersecting){he(r,"intersect");break}}},o);i.observe(ue(r));pt(ue(r),n,t,e)}else if(!t.firstInitCompleted&&e.trigger==="load"){if(!gt(e,r,Mt("load",{elt:r}))){vt(ue(r),n,t,e.delay)}}else if(e.pollInterval>0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e<n.length;e++){const r=n[e].name;if(l(r,"hx-on:")||l(r,"data-hx-on:")||l(r,"hx-on-")||l(r,"data-hx-on-")){return true}}return false}const Ct=(new XPathEvaluator).createExpression('.//*[@*[ starts-with(name(), "hx-on:") or starts-with(name(), "data-hx-on:") or'+' starts-with(name(), "hx-on-") or starts-with(name(), "data-hx-on-") ]]');function Ot(e,t){if(Et(e)){t.push(ue(e))}const n=Ct.evaluate(e);let r=null;while(r=n.iterateNext())t.push(ue(r))}function Rt(e){const t=[];if(e instanceof DocumentFragment){for(const n of e.childNodes){Ot(n,t)}}else{Ot(e,t)}return t}function Ht(e){if(e.querySelectorAll){const n=", [hx-boost] a, [data-hx-boost] a, a[hx-boost], a[data-hx-boost]";const r=[];for(const i in Mn){const s=Mn[i];if(s.getSelectors){var t=s.getSelectors();if(t){r.push(t)}}}const o=e.querySelectorAll(H+n+", form, [type='submit'],"+" [hx-ext], [data-hx-ext], [hx-trigger], [data-hx-trigger]"+r.flat().map(e=>", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;e<t.attributes.length;e++){const n=t.attributes[e].name;const r=t.attributes[e].value;if(l(n,"hx-on")||l(n,"data-hx-on")){const o=n.indexOf("-on")+3;const i=n.slice(o,o+1);if(i==="-"||i===":"){let e=n.slice(o+1);if(l(e,":")){e="htmx"+e}else if(l(e,"-")){e="htmx:"+e.slice(1)}else if(l(e,"htmx-")){e="htmx:"+e.slice(5)}Nt(t,e,r)}}}}function Pt(t){if(g(t,Q.config.disableSelector)){b(t);return}const n=ie(t);const e=Pe(t);if(n.initHash!==e){De(t);n.initHash=e;he(t,"htmx:beforeProcessNode");const r=st(t);const o=wt(t,n,r);if(!o){if(re(t,"hx-boost")==="true"){ft(t,n,r)}else if(s(t,"hx-trigger")){r.forEach(function(e){St(t,e,n,function(){})})}}if(t.tagName==="FORM"||ee(t,"type")==="submit"&&s(t,"form")){At(t)}n.firstInitCompleted=true;he(t,"htmx:afterProcessNode")}}function kt(e){e=y(e);if(g(e,Q.config.disableSelector)){b(e);return}Pt(e);se(Ht(e),function(e){Pt(e)});se(Rt(e),It)}function Dt(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase()}function Mt(e,t){let n;if(window.CustomEvent&&typeof window.CustomEvent==="function"){n=new CustomEvent(e,{bubbles:true,cancelable:true,composed:true,detail:t})}else{n=ne().createEvent("CustomEvent");n.initCustomEvent(e,true,true,t)}return n}function fe(e,t,n){he(e,t,ce({error:t},n))}function Xt(e){return e==="htmx:afterProcessNode"}function Ft(e,t){se(Un(e),function(e){try{t(e)}catch(e){O(e)}})}function O(e){if(console.error){console.error(e)}else if(console.log){console.log("ERROR: ",e)}}function he(e,t,n){e=y(e);if(n==null){n={}}n.elt=e;const r=Mt(t,n);if(Q.logger&&!Xt(t)){Q.logger(e,t,n)}if(n.error){O(n.error);he(e,"htmx:error",{errorInfo:n})}let o=e.dispatchEvent(r);const i=Dt(t);if(o&&i!==t){const s=Mt(i,r.detail);o=o&&e.dispatchEvent(s)}Ft(ue(e),function(e){o=o&&(e.onEvent(t,r)!==false&&!r.defaultPrevented)});return o}let Bt=location.pathname+location.search;function Ut(){const e=ne().querySelector("[hx-history-elt],[data-hx-history-elt]");return e||ne().body}function jt(t,e){if(!B()){return}const n=_t(e);const r=ne().title;const o=window.scrollY;if(Q.config.historyCacheSize<=0){localStorage.removeItem("htmx-history-cache");return}t=U(t);const i=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<i.length;e++){if(i[e].url===t){i.splice(e,1);break}}const s={url:t,content:n,title:r,scroll:o};he(ne().body,"htmx:historyItemCreated",{item:s,cache:i});i.push(s);while(i.length>Q.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e<n.length;e++){if(n[e].url===t){return n[e]}}return null}function _t(e){const t=Q.config.requestClass;const n=e.cloneNode(true);se(x(n,"."+t),function(e){G(e,t)});se(x(n,"[data-disabled-by-htmx]"),function(e){e.removeAttribute("disabled")});return n.innerHTML}function zt(){const e=Ut();const t=Bt||location.pathname+location.search;let n;try{n=ne().querySelector('[hx-history="false" i],[data-hx-history="false" i]')}catch(e){n=ne().querySelector('[hx-history="false"],[data-hx-history="false"]')}if(!n){he(ne().body,"htmx:beforeHistorySave",{path:t,historyElt:e});jt(t,e)}if(Q.config.historyEnabled)history.replaceState({htmx:true},ne().title,window.location.href)}function $t(e){if(Q.config.getCacheBusterParam){e=e.replace(/org\.htmx\.cache-buster=[^&]*&?/,"");if(Y(e,"&")||Y(e,"?")){e=e.slice(0,-1)}}if(Q.config.historyEnabled){history.pushState({htmx:true},"",e)}Bt=e}function Jt(e){if(Q.config.historyEnabled)history.replaceState({htmx:true},"",e);Bt=e}function Kt(e){se(e,function(e){e.call(undefined)})}function Gt(o){const e=new XMLHttpRequest;const i={path:o,xhr:e};he(ne().body,"htmx:historyCacheMiss",i);e.open("GET",o,true);e.setRequestHeader("HX-Request","true");e.setRequestHeader("HX-History-Restore-Request","true");e.setRequestHeader("HX-Current-URL",ne().location.href);e.onload=function(){if(this.status>=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;e<t.length;e++){const r=t[e];if(r.isSameNode(n)){return true}}return false}function tn(e){const t=e;if(t.name===""||t.name==null||t.disabled||g(t,"fieldset[disabled]")){return false}if(t.type==="button"||t.type==="submit"||t.tagName==="image"||t.tagName==="reset"||t.tagName==="file"){return false}if(t.type==="checkbox"||t.type==="radio"){return t.checked}return true}function nn(t,e,n){if(t!=null&&e!=null){if(Array.isArray(e)){e.forEach(function(e){n.append(t,e)})}else{n.append(t,e)}}}function rn(t,n,r){if(t!=null&&n!=null){let e=r.getAll(t);if(Array.isArray(n)){e=e.filter(e=>n.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e<s.length;e++){const l=s[e];if(l.indexOf("swap:")===0){r.swapDelay=d(l.slice(5))}else if(l.indexOf("settle:")===0){r.settleDelay=d(l.slice(7))}else if(l.indexOf("transition:")===0){r.transition=l.slice(11)==="true"}else if(l.indexOf("ignoreTitle:")===0){r.ignoreTitle=l.slice(12)==="true"}else if(l.indexOf("scroll:")===0){const c=l.slice(7);var o=c.split(":");const u=o.pop();var i=o.length>0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t<Q.config.responseHandling.length;t++){var n=Q.config.responseHandling[t];if(In(n,e.status)){return n}}return{swap:false}}function kn(e){if(e){const t=u("title");if(t){t.innerHTML=e}else{window.document.title=e}}}function Dn(o,i){const s=i.xhr;let l=i.target;const e=i.etc;const c=i.select;if(!he(o,"htmx:beforeOnLoad",i))return;if(R(s,/HX-Trigger:/i)){Je(s,"HX-Trigger",o)}if(R(s,/HX-Location:/i)){zt();let e=s.getResponseHeader("HX-Location");var t;if(e.indexOf("{")===0){t=S(e);e=t.path;delete t.path}Rn("get",e,t).then(function(){$t(e)});return}const n=R(s,/HX-Refresh:/i)&&s.getResponseHeader("HX-Refresh")==="true";if(R(s,/HX-Redirect:/i)){i.keepIndicators=true;location.href=s.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){i.keepIndicators=true;location.reload();return}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}const u=Nn(o,i);const r=Pn(s);const a=r.swap;let f=!!r.error;let h=Q.config.ignoreTitle||r.ignoreTitle;let d=r.select;if(r.target){i.target=ue(ae(o,r.target))}var g=e.swapOverride;if(g==null&&r.swapOverride){g=r.swapOverride}if(R(s,/HX-Retarget:/i)){if(s.getResponseHeader("HX-Retarget")==="this"){i.target=o}else{i.target=ue(ae(o,s.getResponseHeader("HX-Retarget")))}}if(R(s,/HX-Reswap:/i)){g=s.getResponseHeader("HX-Reswap")}var p=s.response;var m=ce({shouldSwap:a,serverResponse:p,isError:f,ignoreTitle:h,selectOverride:d,swapOverride:g},i);if(r.event&&!he(l,r.event,m))return;if(!he(l,"htmx:beforeSwap",m))return;l=m.target;p=m.serverResponse;f=m.isError;h=m.ignoreTitle;d=m.selectOverride;g=m.swapOverride;i.target=l;i.failed=f;i.successful=!f;if(m.shouldSwap){if(s.status===286){lt(o)}Ft(o,function(e){p=e.transformResponse(p,s,o)});if(u.type){zt()}var x=gn(o,g);if(!x.hasOwnProperty("ignoreTitle")){x.ignoreTitle=h}l.classList.add(Q.config.swappingClass);let n=null;let r=null;if(c){d=c}if(R(s,/HX-Reselect:/i)){d=s.getResponseHeader("HX-Reselect")}const y=re(o,"hx-select-oob");const b=re(o,"hx-select");let e=function(){try{if(u.type){he(ne().body,"htmx:beforeHistoryUpdate",ce({history:u},i));if(u.type==="push"){$t(u.path);he(ne().body,"htmx:pushedIntoHistory",{path:u.path})}else{Jt(u.path);he(ne().body,"htmx:replacedInHistory",{path:u.path})}}$e(l,p,x,{select:d||b,selectOOB:y,eventInfo:i,anchor:i.pathInfo.anchor,contextElement:o,afterSwapCallback:function(){if(R(s,/HX-Trigger-After-Swap:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Swap",e)}},afterSettleCallback:function(){if(R(s,/HX-Trigger-After-Settle:/i)){let e=o;if(!le(o)){e=ne().body}Je(s,"HX-Trigger-After-Settle",e)}oe(n)}})}catch(e){fe(o,"htmx:swapError",i);oe(r);throw e}};let t=Q.config.globalViewTransitions;if(x.hasOwnProperty("transition")){t=x.transition}if(t&&he(o,"htmx:beforeTransition",i)&&typeof Promise!=="undefined"&&document.startViewTransition){const v=new Promise(function(e,t){n=e;r=t});const w=e;e=function(){document.startViewTransition(function(){w();return v})}}if(x.swapDelay>0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","<style"+e+"> ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} </style>")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}(); \ No newline at end of file diff --git a/templates/partials/modal_note_form.html b/templates/partials/modal_note_form.html new file mode 100644 index 0000000..7000034 --- /dev/null +++ b/templates/partials/modal_note_form.html @@ -0,0 +1,16 @@ +<form method="POST" action="{{ form_action }}" + enctype="multipart/form-data" + hx-post="{{ form_action }}" + hx-target="#modal-body" + hx-swap="innerHTML" + hx-encoding="multipart/form-data"> + {% csrf_token %} + <fieldset> + <legend>{{ legend }}</legend> + {% include "partials/form_fields.html" %} + </fieldset> + <div class="modal-form-actions"> + <button type="button" class="secondary outline" data-close-modal>Cancel</button> + <button type="submit">Save</button> + </div> +</form> From 9382d7c9e294aeafaf5dc1b8c42078eb1f75a2d1 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:23:47 +0200 Subject: [PATCH 10/15] docs(ADRs): updates --- doc/01_adr_functionality.md | 60 +++++++++++-------------- doc/02_adr_django.md | 49 +++++++------------- doc/03_adr_monolit.md | 33 ++++++-------- doc/04_adr_monorepo.md | 42 +++++++---------- doc/05_adr_matlibplot.md | 52 +++++++++------------ doc/06_adr_html_template.md | 62 +++++++++++++++++-------- doc/07_adr_drf.md | 33 +++++++------- doc/08_adr_databases.md | 70 ++++++++++++----------------- doc/09_adr_user_data.md | 1 + doc/10_adr_notification_trigger.md | 72 ++++++++++++++++-------------- 10 files changed, 220 insertions(+), 254 deletions(-) diff --git a/doc/01_adr_functionality.md b/doc/01_adr_functionality.md index b4f56c0..52de912 100644 --- a/doc/01_adr_functionality.md +++ b/doc/01_adr_functionality.md @@ -1,50 +1,40 @@ -## To create a core functionality as a scope of Animals Healthcare Application project +## Core functionality scope of the Animals Healthcare Application - -### Date:  +### Date `2023-06-04` - ### Status In-building - ### Context -We need to create a list of main functions to define business justification of first version of an application and decide what functionality should be canceled or suspended to further implementation in next releases. - +A list of main functions was needed to define the business scope of the first version of the application +and to decide what functionality should be deferred to future releases. ### Decision - -A first brainstorm created a list of basic functions expectted to implement: -- Create a databases to contain at least data like: - - Profiles of animals (starting examples based on chinchilas: age, weight, size, food preferences etc.) - - Profiles of users (not nessesery an owner), - - Profiles of Healtcare places (address, geographic location, historical prices, ratings of individual personnel) and vets of different specialisations (internist, ophthalmologist, dentist, surgeon etc.) - - Different types of calendars (medical visits, bought info of food and feeding's periods, medicines dosage), - - Keeping medical records received from vets in .pdf formats, -- Generate static diagrams on button demand, like a charts of weight and consumed amount of medicines, -- Creat sticky notes kanboard to manage a feeding period per purchased food, -- Sending visit notifications via at least one of: sms, whatsap, messenger, e-mail, discord, -- Printable notes and charts into pdf reports, -- Synchronization into a google calendar, -- Basic API to consider a transfer of charts into Dash-Plotly. - -List to-do's suspended until next iterations of application: -- Interactive dashboards, -- Implementation all of proposed notification methods, -- Direct chat between users, without using animal's notes. - +Initial brainstorm produced the following feature list: + +In scope (first version): +- Animal profiles (age, weight, size, food preferences, etc.) +- User profiles (owner and carers) +- Healthcare place and vet profiles (address, historical prices, ratings) +- Medical calendars (visits, feeding periods, medicine dosage) +- Medical record storage (.pdf attachments via CouchDB — see ADR-08) +- Static charts on demand (weight, medicine consumption) +- Visit notifications via at least one channel (Discord implemented; SMS/email deferred) + +Deferred to future iterations: +- Sticky-note kanban for feeding period tracking +- Printable PDF reports +- Google Calendar synchronisation +- Interactive dashboards (Dash-Plotly microservice — see ADR-05) +- All notification channels beyond Discord (SMS, WhatsApp, Messenger) +- Direct chat between users ### Consequences -An effortful list of functionality has been created to exercise a building process of web applications. -The demands have been divided into quickly attainable goals, leaving a basic draft of a further development. - +The feature set was scoped to an achievable first version, leaving a documented backlog for future iterations. +Deferred features are recorded here rather than in code as TODO comments. ### Keywords -- init, -- functionality, -- scope of project. - +- init, functionality, scope of project ### Links - pass diff --git a/doc/02_adr_django.md b/doc/02_adr_django.md index 454a427..bf18533 100644 --- a/doc/02_adr_django.md +++ b/doc/02_adr_django.md @@ -1,52 +1,37 @@ -## To choose a main web framework for project +## Main web framework — Django selected - -### Date:  +### Date `2023-06-05` - ### Status Done - ### Context -We need to choose a main web framework for the project to create the core of the application.\ -Considered technologies: -- [x] Django, -- [ ] Flask, -- [ ] Dash Plotly. +A main web framework was needed to build the core of the application. +Alternatives considered: +- **Django** — full-featured, batteries-included; ORM, admin, auth, templating out of the box. +- **Flask** — microframework; would require assembling more components manually. +- **Dash Plotly** — derivative of Flask focused on interactive dashboards; dashboard functionality was deferred (see ADR-05). ### Decision -Django was selected. The developer has the most recent experience and the desire to systematize knowledge. - -Flask, as a microframework, could extend the time to the first working prototype. - -Dash is a derivative framework for Flask with extensive features for generating interactive dashboards. -This functionality has been postponed to a later stage of the application development. - +Django was selected. The developer had the most recent hands-on experience with it and wanted to +deepen that knowledge systematically. Flask would have extended time-to-first-prototype with no +offsetting benefit. Dash was out of scope until interactive dashboards are prioritised. ### Consequences -An expected short time to prepare the first working prototype. -With good community support, plugins supporting specific functionalities should be available (ORM, logging, api, etc.). - +- Short time to a working prototype due to Django's built-in ORM, admin, and auth. +- Strong community and plugin ecosystem (DRF, Celery integration, etc.). +- The monolithic architecture (ADR-03) aligns naturally with Django's app model. ### Keywords -- Django, -- Flask, -- Dash Plotly, -- web framework. - +- Django, Flask, Dash Plotly, web framework ### Links *[2023-06-05]*\ -Homepages: - - https://www.djangoproject.com/ - - https://flask.palletsprojects.com - - https://dash.plotly.com/ +https://www.djangoproject.com/\ +https://flask.palletsprojects.com\ +https://dash.plotly.com/ *[2021-11-26]*\ [List of 7 Best Python Frameworks to Consider For Your Web Project](https://www.monocubed.com/blog/top-python-frameworks/) diff --git a/doc/03_adr_monolit.md b/doc/03_adr_monolit.md index fbd98a4..722a302 100644 --- a/doc/03_adr_monolit.md +++ b/doc/03_adr_monolit.md @@ -1,35 +1,28 @@ -## To choose a project architecture +## Application architecture — monolith with optional microservice extensions - -### Date:  +### Date `2023-06-05` - ### Status Done - ### Context -We need to choose an approach to building the project's application structure.\ -Considered approaches: -- [x] Monolith, -- [ ] Microservices. - +An architecture style was needed for the project. The main options were a monolith or a microservices approach. +Django was already selected (ADR-02), which has a natural affinity with the monolithic model. ### Decision -Due to the selection of Django as the main framework, an application in the monolithic architecture will be created, -open via APIs to the possibility of adding selective functionalities in the form of microservices. - +A **monolith** architecture was chosen, open via APIs to selective microservice extensions where justified. +With a single developer and a rapid-prototype goal, starting with a monolith avoids distributed-systems +complexity (service discovery, inter-service auth, deployment overhead) before the core product is validated. ### Consequences -Each new functionality will need to be considered to determine it will be easier to implement as a fragment of the monolith or as a new microservice. - +- All core features (animals, medical notes, notifications) live inside one Django project (`src/ahc/`). +- A future microservice (e.g. interactive dashboards — ADR-05) can be grafted on via a REST API (ADR-07) + without restructuring the monolith. +- Each new feature should be evaluated: monolith addition vs separate microservice. + The default is monolith unless the feature has a clearly distinct deployment or scaling requirement. ### Keywords -- Main architecture, -- Monolith, -- Microservices. - +- architecture, monolith, microservices ### Links - pass diff --git a/doc/04_adr_monorepo.md b/doc/04_adr_monorepo.md index a9da6d9..34556d4 100644 --- a/doc/04_adr_monorepo.md +++ b/doc/04_adr_monorepo.md @@ -1,43 +1,35 @@ -## To choose a repository architecture +## Repository structure — monorepo + GitHub Flow - -### Date:  +### Date `2023-06-05` - ### Status Done - ### Context -We need to choose an approach to building and maintaining the repositories and branches.\ -Considered approaches: -- [x] Monorepo, -- [ ] Polirepo, ---- -- [ ] GitFlow, -- [x] GitHub Flow, -- [ ] GitLab Flow, -- [ ] Trunk-based development. +A repository structure and branching strategy were needed for the project. +Repository options: monorepo vs polyrepo. +Branching options: GitFlow, GitHub Flow, GitLab Flow, trunk-based development. ### Decision -The Monorepo approach will be used due to the small number of developers and the expected number of parallel branches. - -The number of developers also affects the decision to manage branches and approach to deployment. -GitHub-Flow was selected. In a small organization, a least detailed approach will suffice. +**Monorepo** — all code (Django app, Celery worker, Docker configs, Kubernetes manifests, docs) lives +in one repository. With a single developer and a small surface area, a polyrepo would add overhead +(cross-repo dependency tracking, versioned releases per service) with no benefit. +**GitHub Flow** — `main` is always deployable; feature work happens on short-lived branches merged +via pull request. GitFlow's `develop`/`release`/`hotfix` branching model would be overkill for +a one-developer project. ### Consequences -Possible future migrations will be easier in the direction from simpler to more complicated. - +- All changes to any part of the system are visible in one history and can be correlated across layers. +- Migrations from simpler to more complex repository structures (polyrepo, GitFlow) are straightforward + if the team or scope grows. +- The `main` branch is the production baseline; branch names follow Conventional Commits types + (`feat/`, `fix/`, `refactor/`, etc.). ### Keywords -- GitHub, -- repository, -- monorepo, -- branching. - +- GitHub, repository, monorepo, branching, GitHub Flow ### Links *[2023-06-14]*\ diff --git a/doc/05_adr_matlibplot.md b/doc/05_adr_matlibplot.md index 8f171bc..9ff5fab 100644 --- a/doc/05_adr_matlibplot.md +++ b/doc/05_adr_matlibplot.md @@ -1,50 +1,40 @@ -## To create a technology to chart visualisations of data +## Chart visualisation technology — Matplotlib first, Chart.js next - -### Date:  +### Date `2023-06-05` - ### Status Proposed - ### Context -We need to choose a technology to create charts for the application.\ -Considered approaches: -- [x] Static charts: - - [x] Matplotlib, +A technology was needed to render charts (weight trends, medicine consumption, etc.) in the application. +Two categories of solutions were considered: -- [ ] Interactive dashboards: - - [ ] Dash-Plotly microservice, - - [x] Chart.js, - +- **Static charts** (server-rendered image): Matplotlib. +- **Interactive dashboards** (client-side): Chart.js (in-page JS), Dash-Plotly (separate microservice). ### Decision -To avoid the proliferation of microservices, a decision has been made to prototype using a static method of generating charts. -The presented data is not expected to require frequent refreshing and filtering of the range. +**Phase 1 — Matplotlib** (static server-rendered charts): chosen to avoid adding a JavaScript dependency +or a microservice before the core application is stable. The data (biometric records, weight history) +does not require real-time filtering in the initial version. +**Phase 2 — Chart.js** (planned): once the static prototype is validated, Chart.js will be evaluated +as a drop-in replacement. It runs in-browser without a build pipeline, making it compatible with the +no-build-step constraint from ADR-11. Dash-Plotly is deferred indefinitely (it would require +a separate microservice — see ADR-03). ### Consequences -A faster development process and the possibility of future functionality replacement. -After preparing the static prototype, tests with Chart.js will be carried out and the cost of implementation will be estimated. - +- Static Matplotlib charts are generated on-demand server-side and served as images. +- Switching to Chart.js later requires replacing the server-side render path with a JSON data endpoint + and a JS chart component — scoped work, no architectural change. +- Dash-Plotly is not planned unless interactive dashboards become a core requirement. ### Keywords -- Matplotlib, -- Dash Plotly, -- Dashboards, -- Charts, -- Data visualisation. - +- Matplotlib, Chart.js, Dash Plotly, dashboards, charts, data visualisation ### Links *[2023-06-14]*\ -Homepages: - - https://matplotlib.org/ - - https://dash.plotly.com/ - - https://www.chartjs.org/ +https://matplotlib.org/\ +https://www.chartjs.org/\ +https://dash.plotly.com/ diff --git a/doc/06_adr_html_template.md b/doc/06_adr_html_template.md index 62dc2db..80f2096 100644 --- a/doc/06_adr_html_template.md +++ b/doc/06_adr_html_template.md @@ -1,37 +1,63 @@ -## To choose a main HTML template - - -### Date:  -`2023-06-05` +## HTML template framework — PicoCSS selected +### Date +`2023-06-05` (updated `2026-05-31`) ### Status Done - ### Context -We need to choose a main template for frontend part of the project. - +The application uses Django server-side rendering with standard HTML templates. +A CSS framework was needed to provide consistent styling, responsive layout, +and accessible components without adding a JavaScript build pipeline. + +Requirements: +- Semantic HTML-first approach (no utility-class soup) +- Dark mode support +- Minimal JavaScript dependency +- Works well with Django template inheritance + +Alternatives considered: +- **Bootstrap 5** — widely used but class-heavy; overrides are verbose. +- **Tailwind CSS** — requires a build step; conflicts with the no-build-pipeline constraint. +- **Bulma** — no dark mode out of the box. +- **Plain CSS** — too much boilerplate for a rapid prototype. ### Decision -pass +**Pico CSS 2.1.1** was selected. It styles native HTML elements directly (no class annotations needed +for most components), ships a dark mode variant, and requires zero JavaScript. + +Configuration: +- Theme file: `static/css/pico-2.1.1/pico.yellow.min.css` (yellow accent, dark mode). +- All custom overrides and project-specific styles live in `static/css/custom_pico.css`. + This is the single source of truth for new styles — do not override Pico inline or in templates. +- CSS custom properties are defined in `:root` at the top of `custom_pico.css`: +| Variable | Value | Role | +|----------------------|------------------------|-------------------| +| `--ahc-font-display` | Bricolage Grotesque | Headings font | +| `--ahc-font-body` | DM Sans | Body font | + +Both fonts are loaded via Google Fonts (`<link rel="preconnect">` + stylesheet) in `base.html`. ### Consequences -pass +- Pico's opinionated defaults mean most form elements, buttons, and layout primitives look + styled without any class attributes — useful for rapid prototyping. +- Overriding Pico defaults requires understanding its CSS custom property cascade; inspect + `custom_pico.css` before adding new styles to avoid duplicates. +- JavaScript-free modal support uses the native `<dialog>` element styled by Pico (see ADR-11). +- Dark mode is handled by Pico automatically; custom colors must be defined using CSS variables + in both `:root` and `[data-theme="dark"]` scopes if they need to respond to theme switching. ### Keywords -- frontend, -- template, -- HTML, -- JS. - +- frontend, CSS, Pico CSS, dark mode, template, HTML ### Links -*[2023-06-15]*\ -Homepages: +*[2026-05-31]*\ +https://picocss.com/docs - https://picocss.com/ +*[2023-06-15]*\ +https://picocss.com/ *[2023-05-23]*\ [One of the fastest ways to make the Django app look good](https://levelup.gitconnected.com/one-of-the-fastest-ways-to-make-the-django-app-look-good-c2b23006a574) diff --git a/doc/07_adr_drf.md b/doc/07_adr_drf.md index 45335b2..d4ddc02 100644 --- a/doc/07_adr_drf.md +++ b/doc/07_adr_drf.md @@ -1,34 +1,31 @@ -## To choose a main api framework +## API framework — decision pending - -### Date:  +### Date `2023-06-05` - ### Status Proposed - ### Context -We need to choose a basic API framework to inside and outside communication.\ -Considered approaches: -- Django REST Framework, -- Django Ninja, -- FastAPI, -- GraphQL. +An API layer is needed to support potential microservice extensions (ADR-03) and future external integrations. +No API has been implemented yet — the current application is a server-rendered monolith with no public endpoints. +Candidates under consideration: +- **Django REST Framework (DRF)** — mature, widely adopted, large ecosystem; verbose for simple cases. +- **Django Ninja** — modern, type-annotated (Pydantic), faster to write; smaller community than DRF. +- **FastAPI** — excellent performance and type safety, but would run as a separate service outside Django. +- **GraphQL** — flexible querying; adds client-side complexity and a schema maintenance burden. ### Decision -pass +*Pending.* No API framework has been selected. The decision will be made when the first API endpoint +is required by a concrete feature (e.g. Chart.js data feed, mobile client, or microservice integration). ### Consequences -pass - +- Until a decision is made, all data access goes through Django views and Django templates. +- The choice of DRF vs Django Ninja is the most likely outcome given the monolith-first strategy (ADR-03); + both integrate natively with Django's ORM, auth, and permission system. ### Keywords -- api framework, -- REST. - +- API framework, REST, DRF, Django Ninja, FastAPI, GraphQL ### Links - pass diff --git a/doc/08_adr_databases.md b/doc/08_adr_databases.md index 9c7e12a..134ac71 100644 --- a/doc/08_adr_databases.md +++ b/doc/08_adr_databases.md @@ -1,64 +1,52 @@ -## To choose DBMS +## Database stack — PostgreSQL + CouchDB + Redis - -### Date:  +### Date `2023-06-05` - ### Status In-building - ### Context -We need to choose a database for a specific task within the application.\ -Considered DBMS: -- [x] PostgreSQL, -- [ ] MS SQL, -- [ ] MySQL, -- [ ] SQLite, -- [ ] MongoDB, -- [x] Redis --to integrate, -- [ ] Firebird, -- [x] CouchDB. +Three distinct data storage needs were identified: +1. Relational data (users, animals, medical notes) — needs transactions and Django ORM support. +2. File/attachment storage (medical PDFs) — binary blobs do not belong in a relational DB. +3. Async task brokering (Celery) — needs a fast in-memory queue. +Candidates evaluated per role: +- Relational: PostgreSQL, MS SQL, MySQL, SQLite. +- Document/file: CouchDB, MongoDB. +- Broker: Redis. ### Decision -Tree databases have been selected for routing testing. - -PostgreSQL - quick database creation and configuration with a good SQL interface. It has many use cases with Django. +Three databases were selected, each with a dedicated role: -CouchDB - native support for files as attachments. Non-relational database, intended for file storage only. +| Database | Version | Port | Role | +|-------------------|-------------|-------|-------------------------------------------------------| +| **PostgreSQL** | 18 | 5433 | Primary relational store — all Django models | +| **CouchDB** | 3.3.3 | 5982 | Attachment/file storage only (medical PDFs, images) | +| **Redis** | 7 | 6379 | Celery broker + task result backend | -Redis - default broker for Celery queue. +SQLite is used **only** for the test database (activated in `settings.py` when `"test"` in `sys.argv`). +Django database routing is required: the default router sends all ORM queries to PostgreSQL; +CouchDB is accessed directly via its HTTP API (not through Django's ORM). ### Consequences -In basic form database routing is required. -The implementation should be quick, as the second database will be used only for storing attachment files. - +- CouchDB is intentionally narrow in scope — file storage only. No relational queries, no Django models. + Any new file storage feature must use the CouchDB HTTP client, not the Django ORM. +- Redis must be running for Celery workers to start; the application is degraded (no async tasks, + no notifications) if Redis is unavailable. +- Test runs use SQLite (no Docker required); integration tests that need PostgreSQL-specific behaviour + must be marked `@pytest.mark.integration` and run against the real stack. ### Keywords -- DBMS, -- database. - +- DBMS, database, PostgreSQL, CouchDB, Redis, Celery, routing ### Links *[2023-06-14]*\ -Homepages: - - https://www.postgresql.org/ - - https://www.mysql.com/ - - https://www.sqlite.org/index.html - - https://www.mongodb.com/ - - https://redis.io/ - - https://firebirdsql.org/ - - https://couchdb.apache.org/ +https://www.postgresql.org/\ +https://redis.io/\ +https://couchdb.apache.org/ *[2023-01-24]*\ [How to use PostgreSQL with Django](https://www.enterprisedb.com/postgres-tutorials/how-use-postgresql-django) diff --git a/doc/09_adr_user_data.md b/doc/09_adr_user_data.md index fa66bbf..0000164 100644 --- a/doc/09_adr_user_data.md +++ b/doc/09_adr_user_data.md @@ -52,6 +52,7 @@ Full field list: see `users/models.py`. Split into sub-models by note type. See `medical_notes/models/` for current field lists. Core fields: `animal` (FK), `title`, `short_description`, `full_description`, `creation_date`, `modify_date`, `start_event_date`, `end_event_date`, `type_of_event`. +`FeedingNote` additionally carries `purchase_source` (CharField 250, optional) — where to buy the product. ### Consequences - `Animal` fields are edited through the `Change*` pipeline documented in `CLAUDE.md` (Animals App — Conventions). diff --git a/doc/10_adr_notification_trigger.md b/doc/10_adr_notification_trigger.md index 72f9713..6414196 100644 --- a/doc/10_adr_notification_trigger.md +++ b/doc/10_adr_notification_trigger.md @@ -1,51 +1,55 @@ -## To set a tech-stack for notifications - +## Notification delivery — Celery Beat + Django Background Tasks ### Date -`2023-12-19` - +`2023-12-19` (updated `2026-05-31`) ### Status In-building - ### Context -We need to choose a technology for sending set by users notifications. -The basic channel for sending notifications include: -- e-mail, -- sms, -- chatbot (Discord or Messenger). - -Main risks: overwhelming a database by frequent requests. -It is important to use intervals and delays to queue the broker. - -Options: -- Celery Beat, -- django-crontab, -- django-cron. - +The application needs to send time-based notifications to users (e.g. upcoming vet visits). +Key constraints: +- Avoid overwhelming the database with frequent polling. +- Support at least one external channel (Discord is the primary target). +- Work within the existing Django monolith (ADR-03) without adding a separate service. + +Options evaluated: +- **django-crontab** — OS-level cron wrapper; simple but couples scheduling to the server's cron daemon. + No retry logic, no visibility into task state. +- **Celery Beat** — distributed periodic task scheduler; integrates with the Celery worker (Redis broker) + already used for async tasks. Supports retry, monitoring via Flower, and dynamic schedule updates. +- **Django Background Tasks API** (`ImmediateBackend`) — lightweight in-process runner; + no separate worker process needed; suitable for short, non-critical tasks. ### Decision -Django-crontab +**django-crontab was chosen initially but has since been removed.** +The current stack uses two complementary mechanisms: +1. **Celery Beat** (periodic tasks via `celery_notifications/cron.py`) — handles scheduled checks + (e.g. scan for upcoming vet visits and enqueue notification tasks). Runs as a separate + `celery_beat` Docker service with Redis 7 as the broker. -### Consequences +2. **Django Background Tasks API** (`ImmediateBackend`) — used for lightweight in-process tasks + that do not need a separate worker. Configured in `settings.py`. -1. **Integration with Django:** django-crontab is a Django extension, making it a natural choice for seamlessly scheduling tasks in a Django-based application. This integration facilitates code maintenance and management. - -2. **Ease of Use:** django-crontab is easy to configure and use. Leveraging the same mechanisms as Django, it imposes minimal overhead on the development. - -3. **Precise Task Scheduling:** django-crontab allows for precise task scheduling, crucial for handling notifications. Specific 1h time intervals on parametrized minute and easy to count delays can be configured, enabling effective broker queuing and minimizing the risk of database overload. - -4. **Flexibility:** django-crontab offers flexibility in configuring cron tasks. This enables tailoring settings to the specific requirements of the project and adapting to potential future changes. +Notification channel: **Discord** via `discord.py`. Additional channels (e-mail, SMS) are deferred. +### Consequences +- The `homepage.CronJob` model is an **orphan** — it was populated by django-crontab and nothing + currently writes to it. Follow-up required: migrate it or drop the table (tracked in CLAUDE.md + under Known Refactoring Targets). +- Celery Beat requires two running processes: the Celery worker (`queue` service) and the Beat + scheduler (`celery_beat` service). Both are defined in `docker/docker-compose.yml`. +- Task visibility is available via **Celery Flower** (port 5555). +- Adding a new notification type means: (a) write a task function in `celery_notifications/cron.py`, + (b) register it in the Celery Beat schedule in `celery_notifications/config.py`. ### Keywords -- Celery, -- Cronjobs, -- queue, -- broker, -- subscriptions,. - +- Celery, Celery Beat, notifications, Discord, cron, queue, broker, background tasks ### Links +*[2026-05-31]*\ +https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html + +*[2023-12-19]*\ +https://docs.djangoproject.com/en/stable/topics/db/multi-db/ (database routing reference) From 0696992cdb754d7cb9255b4c3ae751e48293f6d6 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:54:30 +0200 Subject: [PATCH 11/15] feat(timeline): pagination, jump into --- .../templates/animals/tabs/_notes.html | 31 +++++ .../animals/templates/animals/tabs/_vet.html | 31 +++++ .../tabs/partials/_timeline_nodes_notes.html | 31 +++++ .../tabs/partials/_timeline_nodes_vet.html | 22 ++++ src/ahc/apps/animals/views.py | 87 +++++++++++++- .../homepage/templates/homepage/base.html | 1 + src/ahc/apps/medical_notes/selectors.py | 60 ++++++++++ .../medical_notes/full_timeline_of_notes.html | 110 ++++-------------- .../partials/_timeline_page.html | 97 +++++++++++++++ .../partials/_timeline_pagination.html | 48 ++++++++ .../medical_notes/views/type_basic_note.py | 71 +++++++---- static/css/timeline.css | 41 +++++++ static/js/tabs.js | 3 + static/js/timeline_jump.js | 28 +++++ 14 files changed, 545 insertions(+), 116 deletions(-) create mode 100644 src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html create mode 100644 src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html create mode 100644 src/ahc/apps/medical_notes/templates/medical_notes/partials/_timeline_page.html create mode 100644 src/ahc/apps/medical_notes/templates/medical_notes/partials/_timeline_pagination.html create mode 100644 static/js/timeline_jump.js diff --git a/src/ahc/apps/animals/templates/animals/tabs/_notes.html b/src/ahc/apps/animals/templates/animals/tabs/_notes.html index 1b5c40e..077bb25 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/_notes.html +++ b/src/ahc/apps/animals/templates/animals/tabs/_notes.html @@ -12,11 +12,31 @@ <h3>Notes</h3> hx-swap="innerHTML" data-modal-title="New note">Add a note</a> <a role="button" class="secondary outline" href="{% url 'full_timeline_of_notes' pk=animal.id %}">View full timeline</a> + {% if available_months %} + <select + hx-get="{% url 'animal_tab' pk=animal.id slug='notes' %}" + hx-target="#tab-panels" + hx-swap="innerHTML" + hx-push-url="true" + name="month" + aria-label="Jump to month"> + <option value="">— Jump to month —</option> + {% for month in available_months %} + <option value="{{ month|date:'Y-m' }}" {% if scroll_to_month == month|date:"Y-m" %}selected{% endif %}> + {{ month|date:"F Y" }} + </option> + {% endfor %} + </select> + {% endif %} </div> </div> + <span data-scroll-month="{{ scroll_to_month }}" hidden></span> {% if other_records %} <ol> {% for record in other_records %} + {% ifchanged record.date_creation|date:"Y-m" %} + <li class="month-start" id="tlmonth-notes-{{ record.date_creation|date:'Y-m' }}" data-month="{{ record.date_creation|date:'M Y' }}"></li> + {% endifchanged %} <li> <div> <time>{{ record.date_creation|date:"Y-m-d" }}</time> @@ -33,6 +53,17 @@ <h3>Notes</h3> </div> </li> {% endfor %} + {% if tl_has_more %} + <li class="timeline-load-more" id="timeline-more-notes"> + <div> + <a role="button" class="secondary outline" + href="{% url 'animal_tab' pk=animal.id slug='notes' %}?before={{ tl_next_before }}&load_more=1" + hx-get="{% url 'animal_tab' pk=animal.id slug='notes' %}?before={{ tl_next_before }}&load_more=1" + hx-target="#timeline-more-notes" + hx-swap="outerHTML">Load older</a> + </div> + </li> + {% endif %} <li></li> </ol> {% else %} diff --git a/src/ahc/apps/animals/templates/animals/tabs/_vet.html b/src/ahc/apps/animals/templates/animals/tabs/_vet.html index 308d97b..c5ad027 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/_vet.html +++ b/src/ahc/apps/animals/templates/animals/tabs/_vet.html @@ -35,11 +35,31 @@ <h3>Medical visit timeline</h3> hx-swap="innerHTML" data-modal-title="Add vet visit">Add vet visit</a> <a role="button" class="secondary outline" href="{% url 'full_timeline_of_notes' pk=animal.id %}?type_of_event=medical_visit">View all visits</a> + {% if available_months %} + <select + hx-get="{% url 'animal_tab' pk=animal.id slug='vet' %}" + hx-target="#tab-panels" + hx-swap="innerHTML" + hx-push-url="true" + name="month" + aria-label="Jump to month"> + <option value="">— Jump to month —</option> + {% for month in available_months %} + <option value="{{ month|date:'Y-m' }}" {% if scroll_to_month == month|date:"Y-m" %}selected{% endif %}> + {{ month|date:"F Y" }} + </option> + {% endfor %} + </select> + {% endif %} </div> </div> + <span data-scroll-month="{{ scroll_to_month }}" hidden></span> {% if vet_records %} <ol> {% for record in vet_records %} + {% ifchanged record.date_creation|date:"Y-m" %} + <li class="month-start" id="tlmonth-vet-{{ record.date_creation|date:'Y-m' }}" data-month="{{ record.date_creation|date:'M Y' }}"></li> + {% endifchanged %} <li> <div> <time>{{ record.date_event_started|date:"Y-m-d" }}</time> @@ -47,6 +67,17 @@ <h3>Medical visit timeline</h3> </div> </li> {% endfor %} + {% if tl_has_more %} + <li class="timeline-load-more" id="timeline-more-vet"> + <div> + <a role="button" class="secondary outline" + href="{% url 'animal_tab' pk=animal.id slug='vet' %}?before={{ tl_next_before }}&load_more=1" + hx-get="{% url 'animal_tab' pk=animal.id slug='vet' %}?before={{ tl_next_before }}&load_more=1" + hx-target="#timeline-more-vet" + hx-swap="outerHTML">Load older</a> + </div> + </li> + {% endif %} <li></li> </ol> {% else %} 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" %} +<li class="month-start" id="tlmonth-notes-{{ record.date_creation|date:'Y-m' }}" data-month="{{ record.date_creation|date:'M Y' }}"></li> +{% endifchanged %} +<li> + <div> + <time>{{ record.date_creation|date:"Y-m-d" }}</time> + <a href="{% url 'note_edit' pk=record.id %}">{{ record.short_description }}</a> + <small>({{ record.type_of_event }})</small> + <br> + <a role="button" class="secondary outline" + href="{% url 'note_edit' pk=record.id %}" + hx-get="{% url 'note_edit' pk=record.id %}" + hx-target="#modal-body" + hx-swap="innerHTML" + data-modal-title="Edit note">Edit</a> + <a role="button" class="secondary outline" href="{% url 'note_delete' pk=record.id %}">Delete</a> + </div> +</li> +{% endfor %} +{% if tl_has_more %} +<li class="timeline-load-more" id="timeline-more-notes"> + <div> + <a role="button" class="secondary outline" + href="{% url 'animal_tab' pk=animal.id slug='notes' %}?before={{ tl_next_before }}&load_more=1" + hx-get="{% url 'animal_tab' pk=animal.id slug='notes' %}?before={{ tl_next_before }}&load_more=1" + hx-target="#timeline-more-notes" + hx-swap="outerHTML">Load older</a> + </div> +</li> +{% 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" %} +<li class="month-start" id="tlmonth-vet-{{ record.date_creation|date:'Y-m' }}" data-month="{{ record.date_creation|date:'M Y' }}"></li> +{% endifchanged %} +<li> + <div> + <time>{{ record.date_event_started|date:"Y-m-d" }}</time> + <a href="{% url 'note_edit' pk=record.id %}">{{ record.short_description }}</a> + </div> +</li> +{% endfor %} +{% if tl_has_more %} +<li class="timeline-load-more" id="timeline-more-vet"> + <div> + <a role="button" class="secondary outline" + href="{% url 'animal_tab' pk=animal.id slug='vet' %}?before={{ tl_next_before }}&load_more=1" + hx-get="{% url 'animal_tab' pk=animal.id slug='vet' %}?before={{ tl_next_before }}&load_more=1" + hx-target="#timeline-more-vet" + hx-swap="outerHTML">Load older</a> + </div> +</li> +{% endif %} diff --git a/src/ahc/apps/animals/views.py b/src/ahc/apps/animals/views.py index 87d73e6..453c651 100644 --- a/src/ahc/apps/animals/views.py +++ b/src/ahc/apps/animals/views.py @@ -2,12 +2,14 @@ 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 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 @@ -36,11 +38,53 @@ def _build_mainpage(request, animal: Animal) -> 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) -> dict[str, Any]: - from ahc.apps.medical_notes.selectors import timeline_for + 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] return { - "vet_records": timeline_for(animal, type_of_event="medical_visit").order_by("-date_creation"), + "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"), } @@ -61,7 +105,42 @@ def _build_medications(request, animal: Animal) -> dict[str, Any]: def _build_notes(request, animal: Animal) -> dict[str, Any]: from ahc.apps.medical_notes.selectors import other_records_for - return {"other_records": other_records_for(animal)} + qs = other_records_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_records_for(animal).datetimes( + "date_creation", + "month", + order="DESC", + tzinfo=timezone.get_current_timezone(), + ) + ) + + return { + "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, + } def _build_ownership(request, animal: Animal) -> dict[str, Any]: @@ -168,6 +247,8 @@ def test_func(self): 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"] diff --git a/src/ahc/apps/homepage/templates/homepage/base.html b/src/ahc/apps/homepage/templates/homepage/base.html index fd8ce72..4dc1604 100644 --- a/src/ahc/apps/homepage/templates/homepage/base.html +++ b/src/ahc/apps/homepage/templates/homepage/base.html @@ -86,6 +86,7 @@ {% block extra_js %}{% endblock %} <script defer src="{% static 'js/vendor/htmx.min.js' %}"></script> <script defer src="{% static 'js/tabs.js' %}"></script> +<script defer src="{% static 'js/timeline_jump.js' %}"></script> <script defer src="{% static 'js/hiding_note_fields_in_form.js' %}"></script> <script defer src="{% static 'js/tag_input.js' %}"></script> <script defer src="{% static 'js/animal_select.js' %}"></script> diff --git a/src/ahc/apps/medical_notes/selectors.py b/src/ahc/apps/medical_notes/selectors.py index 806ff6d..bb3758b 100644 --- a/src/ahc/apps/medical_notes/selectors.py +++ b/src/ahc/apps/medical_notes/selectors.py @@ -7,7 +7,11 @@ from __future__ import annotations +from datetime import date, datetime + +from django.db.models import DateTimeField as _DateTimeField from django.db.models import QuerySet +from django.utils import timezone from ahc.apps.animals.selectors import animals_visible_to, user_can_access_animal from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord, MedicalRecordAttachment @@ -43,6 +47,62 @@ def timeline_for( return queryset +def available_months_for( + animal, + type_of_event: str | None = None, + tag_name: str | None = None, +) -> list: + """Return distinct months (newest first) for which the animal has records. + + Months are computed in the active timezone so they match what the template + renders via ``|date:"Y-m"``. The returned list contains aware datetime + objects truncated to month precision (day=1, time=midnight). + """ + return list( + timeline_for(animal, type_of_event=type_of_event, tag_name=tag_name).datetimes( + "date_creation", + "month", + order="DESC", + tzinfo=timezone.get_current_timezone(), + ) + ) + + +def page_of_month( + queryset: QuerySet, + target_month: date, + per_page: int, + date_field: str = "date_creation", +) -> int: + """Return the 1-based page number (newest-first order) containing target_month. + + Counts how many records fall strictly after target_month (i.e. their date + is >= the first day of the following month), then divides by per_page. + Works for both DateTimeField (boundary is an aware local datetime) and + DateField (boundary is a plain date). + + Pass the same ordered+filtered queryset used for pagination so that the + count is consistent with the actual pages produced. + """ + if target_month.month == 12: + first_of_next = date(target_month.year + 1, 1, 1) + else: + first_of_next = date(target_month.year, target_month.month + 1, 1) + + model_field = queryset.model._meta.get_field(date_field) + if isinstance(model_field, _DateTimeField): + tz = timezone.get_current_timezone() + boundary = timezone.make_aware( + datetime(first_of_next.year, first_of_next.month, first_of_next.day, 0, 0, 0), + tz, + ) + else: + boundary = first_of_next + + newer = queryset.filter(**{f"{date_field}__gte": boundary}).count() + return newer // per_page + 1 + + def feeding_notes_for(medical_record) -> QuerySet: """Return all FeedingNotes linked to the given MedicalRecord.""" from ahc.apps.medical_notes.models.type_feeding_notes import FeedingNote diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/full_timeline_of_notes.html b/src/ahc/apps/medical_notes/templates/medical_notes/full_timeline_of_notes.html index 02fb123..fa06038 100644 --- a/src/ahc/apps/medical_notes/templates/medical_notes/full_timeline_of_notes.html +++ b/src/ahc/apps/medical_notes/templates/medical_notes/full_timeline_of_notes.html @@ -5,96 +5,30 @@ {% endblock %} {% block content %} -{% for note, form in notes %} -<article> - <header> - <a href="{% url 'animal_profile' pk=note.animal.id %}">{{ note.animal.full_name }}</a> - <br> - <small>{{ note.date_creation }}</small> - <br> - <small> - Type: - <a href="{% url 'full_timeline_of_notes' pk=note.animal.id %}?{% if request.GET.type_of_event %}type_of_event={{ request.GET.type_of_event }}&{% endif %}type_of_event={{ note.type_of_event }}">{{ note.type_of_event }}</a> - </small> - </header> - - <p>{{ note.short_description }}</p> - - <div> - <h4>Appendixes:</h4> - {% for attachment in note.attachments.all %} - <div class="grid"> - <div> - <p>Uploaded: {{ attachment.upload_date }}</p> - </div> - <div> - <a href="{% url 'attachment_download' id=attachment.couch_id name=attachment.file_name %}" - download="{% url 'attachment_download' id=attachment.couch_id name=attachment.file_name %}"> - Download: {{ attachment.file_name }} - </a> - </div> - <div> - {% if attachment.description %} - <p class="truncate" data-full-text="{{ attachment.description }}">{{ attachment.description }}</p> - {% else %} - <p>No description</p> - {% endif %} - </div> - <div> - <a role="button" class="secondary outline" href="{% url 'attachment_edit' pk=attachment.id %}">Edit</a> - </div> - <div> - <a role="button" class="secondary outline" href="{% url 'attachment_delete' pk=attachment.id %}">Delete</a> - </div> - </div> +{% if available_months %} +<div class="timeline-controls"> + <select + hx-get="{% url 'full_timeline_of_notes' pk=request.resolver_match.kwargs.pk %}" + hx-target="#timeline-results" + hx-swap="innerHTML" + hx-push-url="true" + hx-include="[name='type_of_event'],[name='tag_name']" + name="month" + aria-label="Jump to month"> + <option value="">— Jump to month —</option> + {% for month in available_months %} + <option value="{{ month|date:'Y-m' }}" {% if scroll_to_month == month|date:"Y-m" %}selected{% endif %}> + {{ month|date:"F Y" }} + </option> {% endfor %} + </select> + {% if type_of_event %}<input type="hidden" name="type_of_event" value="{{ type_of_event }}">{% endif %} + {% if tag_name %}<input type="hidden" name="tag_name" value="{{ tag_name }}">{% endif %} +</div> +{% endif %} - <form method="post" action="{% url 'full_timeline_of_notes' pk=note.animal.id %}" enctype="multipart/form-data"> - {% csrf_token %} - {% include "partials/form_fields.html" %} - <button type="submit">Set appendix</button> - </form> - </div> - - {% if note.additional_animals.all %} - <p>Related also to: - {% for animal in note.additional_animals.all %} - <a href="{% url 'animal_profile' pk=animal.id %}">{{ animal.full_name }}</a>{% if not forloop.last %}, {% endif %} - {% endfor %} - </p> - {% endif %} - - {% if note.note_tags.all %} - <p> - Tags: - {% for tag in note.note_tags.all %} - <a href="{% url 'full_timeline_of_notes' pk=note.animal.id %}?{% if request.GET.type_of_event %}type_of_event={{ request.GET.type_of_event }}&{% endif %}tag_name={{ tag.slug }}">#{{ tag.name }}</a>{% if not forloop.last %}, {% endif %} - {% endfor %} - </p> - {% endif %} - - <footer> - <div class="grid"> - <a href="{% url 'note_edit' pk=note.id %}">View full note</a> - <a href="{% url 'note_animals_edit' pk=note.id %}">Change related animals</a> - {% if note.type_of_event == 'diet_note' %} - <a href="{% url 'note_related_diets' pk=note.id %}">View diet</a> - <a href="{% url 'note_related_notifications' %}?mednote_uuid={{ note.id }}">Check notifications</a> - {% endif %} - <a href="{% url 'note_delete' pk=note.id %}">Delete</a> - </div> - </footer> -</article> -{% endfor %} - -<div class="grid"> - <div> - <small> - <a href="{% url 'animal_profile' pk=request.resolver_match.kwargs.pk %}">Return to the pet profile</a> - </small> - </div> - - {% include "partials/pagination.html" %} +<div id="timeline-results"> + {% include "medical_notes/partials/_timeline_page.html" %} </div> {% endblock %} diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/partials/_timeline_page.html b/src/ahc/apps/medical_notes/templates/medical_notes/partials/_timeline_page.html new file mode 100644 index 0000000..5394610 --- /dev/null +++ b/src/ahc/apps/medical_notes/templates/medical_notes/partials/_timeline_page.html @@ -0,0 +1,97 @@ +{% load static %} +<span data-scroll-month="{{ scroll_to_month }}" hidden></span> + +{% for note, form in notes %} +{% ifchanged note.date_creation|date:"Y-m" %} +<h4 id="month-{{ note.date_creation|date:'Y-m' }}">{{ note.date_creation|date:"F Y" }}</h4> +{% endifchanged %} +<article> + <header> + <a href="{% url 'animal_profile' pk=note.animal.id %}">{{ note.animal.full_name }}</a> + <br> + <small>{{ note.date_creation }}</small> + <br> + <small> + Type: + <a href="{% url 'full_timeline_of_notes' pk=note.animal.id %}?{% if type_of_event %}type_of_event={{ type_of_event }}&{% endif %}type_of_event={{ note.type_of_event }}">{{ note.type_of_event }}</a> + </small> + </header> + + <p>{{ note.short_description }}</p> + + <div> + <h4>Appendixes:</h4> + {% for attachment in note.attachments.all %} + <div class="grid"> + <div> + <p>Uploaded: {{ attachment.upload_date }}</p> + </div> + <div> + <a href="{% url 'attachment_download' id=attachment.couch_id name=attachment.file_name %}" + download="{% url 'attachment_download' id=attachment.couch_id name=attachment.file_name %}"> + Download: {{ attachment.file_name }} + </a> + </div> + <div> + {% if attachment.description %} + <p class="truncate" data-full-text="{{ attachment.description }}">{{ attachment.description }}</p> + {% else %} + <p>No description</p> + {% endif %} + </div> + <div> + <a role="button" class="secondary outline" href="{% url 'attachment_edit' pk=attachment.id %}">Edit</a> + </div> + <div> + <a role="button" class="secondary outline" href="{% url 'attachment_delete' pk=attachment.id %}">Delete</a> + </div> + </div> + {% endfor %} + + <form method="post" action="{% url 'full_timeline_of_notes' pk=note.animal.id %}" enctype="multipart/form-data"> + {% csrf_token %} + {% include "partials/form_fields.html" %} + <button type="submit">Set appendix</button> + </form> + </div> + + {% if note.additional_animals.all %} + <p>Related also to: + {% for animal in note.additional_animals.all %} + <a href="{% url 'animal_profile' pk=animal.id %}">{{ animal.full_name }}</a>{% if not forloop.last %}, {% endif %} + {% endfor %} + </p> + {% endif %} + + {% if note.note_tags.all %} + <p> + Tags: + {% for tag in note.note_tags.all %} + <a href="{% url 'full_timeline_of_notes' pk=note.animal.id %}?{% if type_of_event %}type_of_event={{ type_of_event }}&{% endif %}tag_name={{ tag.slug }}">#{{ tag.name }}</a>{% if not forloop.last %}, {% endif %} + {% endfor %} + </p> + {% endif %} + + <footer> + <div class="grid"> + <a href="{% url 'note_edit' pk=note.id %}">View full note</a> + <a href="{% url 'note_animals_edit' pk=note.id %}">Change related animals</a> + {% if note.type_of_event == 'diet_note' %} + <a href="{% url 'note_related_diets' pk=note.id %}">View diet</a> + <a href="{% url 'note_related_notifications' %}?mednote_uuid={{ note.id }}">Check notifications</a> + {% endif %} + <a href="{% url 'note_delete' pk=note.id %}">Delete</a> + </div> + </footer> +</article> +{% endfor %} + +<div class="grid"> + <div> + <small> + <a href="{% url 'animal_profile' pk=request.resolver_match.kwargs.pk %}">Return to the pet profile</a> + </small> + </div> + + {% include "medical_notes/partials/_timeline_pagination.html" %} +</div> diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/partials/_timeline_pagination.html b/src/ahc/apps/medical_notes/templates/medical_notes/partials/_timeline_pagination.html new file mode 100644 index 0000000..8d2605e --- /dev/null +++ b/src/ahc/apps/medical_notes/templates/medical_notes/partials/_timeline_pagination.html @@ -0,0 +1,48 @@ +{% if paginator.page_range|length > 1 %} +<nav> + <small>Pages:</small> + + {% if page_obj.has_previous %} + <a role="button" class="secondary outline" + href="?page=1{% if base_query %}&{{ base_query }}{% endif %}" + hx-get="?page=1{% if base_query %}&{{ base_query }}{% endif %}" + hx-target="#timeline-results" + hx-swap="innerHTML" + hx-push-url="true">First</a> + <a role="button" class="secondary outline" + href="?page={{ page_obj.previous_page_number }}{% if base_query %}&{{ base_query }}{% endif %}" + hx-get="?page={{ page_obj.previous_page_number }}{% if base_query %}&{{ base_query }}{% endif %}" + hx-target="#timeline-results" + hx-swap="innerHTML" + hx-push-url="true">Previous</a> + {% endif %} + + {% for num in paginator.page_range %} + {% if page_obj.number == num %} + <a role="button" href="?page={{ num }}{% if base_query %}&{{ base_query }}{% endif %}">{{ num }}</a> + {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} + <a role="button" class="secondary outline" + href="?page={{ num }}{% if base_query %}&{{ base_query }}{% endif %}" + hx-get="?page={{ num }}{% if base_query %}&{{ base_query }}{% endif %}" + hx-target="#timeline-results" + hx-swap="innerHTML" + hx-push-url="true">{{ num }}</a> + {% endif %} + {% endfor %} + + {% if page_obj.has_next %} + <a role="button" class="secondary outline" + href="?page={{ page_obj.next_page_number }}{% if base_query %}&{{ base_query }}{% endif %}" + hx-get="?page={{ page_obj.next_page_number }}{% if base_query %}&{{ base_query }}{% endif %}" + hx-target="#timeline-results" + hx-swap="innerHTML" + hx-push-url="true">Next</a> + <a role="button" class="secondary outline" + href="?page={{ paginator.num_pages }}{% if base_query %}&{{ base_query }}{% endif %}" + hx-get="?page={{ paginator.num_pages }}{% if base_query %}&{{ base_query }}{% endif %}" + hx-target="#timeline-results" + hx-swap="innerHTML" + hx-push-url="true">Last</a> + {% endif %} +</nav> +{% endif %} 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 c46bba0..2584bf0 100644 --- a/src/ahc/apps/medical_notes/views/type_basic_note.py +++ b/src/ahc/apps/medical_notes/views/type_basic_note.py @@ -1,10 +1,10 @@ +from datetime import datetime + from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.core.paginator import Paginator from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect from django.urls import reverse -from django.utils import timezone from django.views.generic import View from django.views.generic.edit import DeleteView, FormView, UpdateView from django.views.generic.list import ListView @@ -19,8 +19,10 @@ from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord, MedicalRecordAttachment from ahc.apps.medical_notes.selectors import ( animal_choices_for, + available_months_for, can_access_note_animal, is_attachment_author, + page_of_month, timeline_for, ) from ahc.apps.medical_notes.services.attachments import ( @@ -99,35 +101,57 @@ class FullTimelineOfNotes(LoginRequiredMixin, AnimalDirectAccessRequiredMixin, L context_object_name = "notes" paginate_by = 4 - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) + def get_template_names(self): + if self.request.headers.get("HX-Request"): + return ["medical_notes/partials/_timeline_page.html"] + return [self.template_name] - animal = get_object_or_404(AnimalProfile, id=self.kwargs.get("pk")) - query = timeline_for( - animal, + def get_queryset(self): + self._animal = get_object_or_404(AnimalProfile, id=self.kwargs.get("pk")) + return timeline_for( + self._animal, type_of_event=self.request.GET.get("type_of_event"), tag_name=self.request.GET.get("tag_name"), - ) + ).order_by("-date_creation") - # Localise auto-timestamps to the user's current timezone (presentation layer) - user_timezone = timezone.get_current_timezone() - for record in query: - record.date_creation = timezone.localtime(record.date_creation, user_timezone) - record.date_updated = timezone.localtime(record.date_updated, user_timezone) - for attachment in record.attachments.all(): - attachment.upload_date = timezone.localtime(attachment.upload_date, user_timezone) + def paginate_queryset(self, queryset, page_size): + month_param = self.request.GET.get("month") + if month_param and not self.request.GET.get("page"): + try: + target_date = datetime.strptime(month_param, "%Y-%m").date() + page_num = page_of_month(queryset, target_date, page_size) + self.kwargs[self.page_kwarg] = page_num + except ValueError: + pass + return super().paginate_queryset(queryset, page_size) - paginator = Paginator(list(query.order_by("-date_creation")), per_page=self.paginate_by) - notes = paginator.get_page(self.request.GET.get("page")) - context["notes"] = notes + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + page_notes = list(context["page_obj"]) upload_forms = [] - for note in context["notes"]: + for note in page_notes: form = UploadAppendixForm() form.fields["medical_record_id"].initial = str(note.id) upload_forms.append(form) - - context["notes"] = zip(context["notes"], upload_forms, strict=False) + context["notes"] = zip(page_notes, upload_forms, strict=False) + + type_of_event = self.request.GET.get("type_of_event", "") + tag_name = self.request.GET.get("tag_name", "") + context["available_months"] = available_months_for( + self._animal, + type_of_event=type_of_event or None, + tag_name=tag_name or None, + ) + context["scroll_to_month"] = self.request.GET.get("month", "") + context["type_of_event"] = type_of_event + context["tag_name"] = tag_name + base_parts = [] + if type_of_event: + base_parts.append(f"type_of_event={type_of_event}") + if tag_name: + base_parts.append(f"tag_name={tag_name}") + context["base_query"] = "&".join(base_parts) return context @@ -150,10 +174,7 @@ def post(self, request, *args, **kwargs): for _field, errors in form.errors.items(): messages.error(request, f"Failed to upload: {', '.join(str(e) for e in errors)}") - return redirect(request.path) - - def render_to_response(self, context, **response_kwargs): - return super().render_to_response(context, **response_kwargs) + return redirect(request.get_full_path()) class EditNoteView(LoginRequiredMixin, NoteAuthorRequiredMixin, UpdateView): diff --git a/static/css/timeline.css b/static/css/timeline.css index e2254f9..9f57006 100644 --- a/static/css/timeline.css +++ b/static/css/timeline.css @@ -193,3 +193,44 @@ margin-bottom: 8px; color: var(--timeline-midnight-green); } + +/* Month-start node: a wider segment with a month label above the line */ +.timeline ol li.month-start { + width: 200px; + background: var(--timeline-yellow); +} + +.timeline ol li.month-start::before { + content: attr(data-month); + position: absolute; + bottom: calc(100% + 8px); + left: 0; + font-size: 0.75rem; + font-weight: bold; + color: var(--timeline-midnight-green); + white-space: nowrap; + background: var(--timeline-yellow); + padding: 2px 6px; + border-radius: 4px; +} + +/* Controls area: month select and load-more button spacing */ +.records-actions select { + margin-bottom: 0.25rem; + font-size: 0.875rem; +} + +.timeline-load-more div { + display: flex; + align-items: center; + justify-content: center; +} + +/* Full-page timeline: month heading anchors */ +.timeline-controls { + margin-bottom: 1rem; +} + +.timeline-controls select { + max-width: 220px; +} diff --git a/static/js/tabs.js b/static/js/tabs.js index b0b70f8..e6b937d 100644 --- a/static/js/tabs.js +++ b/static/js/tabs.js @@ -41,5 +41,8 @@ if (typeof initPinButton === "function") { initPinButton(); } + if (typeof initTimelineJump === "function") { + initTimelineJump(); + } }); }()); diff --git a/static/js/timeline_jump.js b/static/js/timeline_jump.js new file mode 100644 index 0000000..fd82568 --- /dev/null +++ b/static/js/timeline_jump.js @@ -0,0 +1,28 @@ +// Scroll the timeline to the target month after a month-jump swap or on initial page load. +// initTimelineJump() is called on window load (deep-link support) and from tabs.js +// after every htmx swap. + +function initTimelineJump() { + var marker = document.querySelector("[data-scroll-month]"); + if (!marker) return; + var month = marker.getAttribute("data-scroll-month"); + if (!month) return; + + // Full-page timeline: vertical scroll to <h4 id="month-YYYY-MM"> + var anchor = document.getElementById("month-" + month); + if (anchor) { + anchor.scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + + // Tab horizontal timelines: node anchors follow the pattern "tlmonth-<slug>-YYYY-MM" + var nodes = document.querySelectorAll("[id$='-" + month + "']"); + for (var i = 0; i < nodes.length; i++) { + if (nodes[i].id.indexOf("tlmonth-") === 0) { + nodes[i].scrollIntoView({ behavior: "smooth", block: "nearest", inline: "start" }); + return; + } + } +} + +window.addEventListener("load", initTimelineJump); From edd8be4ed5de6ab08a42931de7412b0634bd0357 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:20:36 +0200 Subject: [PATCH 12/15] feat(animals): per-category access control for animal shares --- doc/09_adr_user_data.md | 64 +++++++++- .../0004_add_animalshare_and_sharedefaults.py | 118 ++++++++++++++++++ .../0005_add_vaccination_share_category.py | 22 ++++ src/ahc/apps/animals/models.py | 67 +++++++++- src/ahc/apps/animals/selectors.py | 56 ++++++++- src/ahc/apps/animals/services.py | 52 ++++++-- .../animals/templates/animals/edit_share.html | 10 ++ .../templates/animals/manage_keepers.html | 50 ++++---- .../animals/templates/animals/tabs/_diet.html | 6 + .../templates/animals/tabs/_mainpage.html | 2 + .../templates/animals/tabs/_medications.html | 6 + .../templates/animals/tabs/_notes.html | 27 ++++ .../templates/animals/tabs/_ownership.html | 33 ++++- .../animals/templates/animals/tabs/_vet.html | 10 +- src/ahc/apps/animals/tests.py | 45 +++---- src/ahc/apps/animals/urls.py | 1 + src/ahc/apps/animals/utils_owner/forms.py | 58 ++++++++- src/ahc/apps/animals/utils_owner/views.py | 57 ++++++++- .../homepage/templates/homepage/base.html | 7 +- src/ahc/apps/users/forms.py | 24 ++++ .../users/templates/users/share_defaults.html | 18 +++ src/ahc/apps/users/urls.py | 1 + src/ahc/apps/users/views.py | 25 +++- templates/partials/form_fields.html | 7 ++ 24 files changed, 680 insertions(+), 86 deletions(-) create mode 100644 src/ahc/apps/animals/migrations/0004_add_animalshare_and_sharedefaults.py create mode 100644 src/ahc/apps/animals/migrations/0005_add_vaccination_share_category.py create mode 100644 src/ahc/apps/animals/templates/animals/edit_share.html create mode 100644 src/ahc/apps/users/templates/users/share_defaults.html diff --git a/doc/09_adr_user_data.md b/doc/09_adr_user_data.md index 0000164..3b6b5a5 100644 --- a/doc/09_adr_user_data.md +++ b/doc/09_adr_user_data.md @@ -1,7 +1,7 @@ ## Data model — stored fields per entity ### Date -`2023-07-09` (updated `2026-05-31`) +`2023-07-09` (updated `2026-06-01`) ### Status In-building @@ -25,7 +25,7 @@ This ADR is a living document — update it when new fields are added. | `profile_image` | ImageField | default | Defaults to `profile_pics/pet-care.png` | | `creation_date` | DateTimeField | auto | `auto_now_add`, non-editable | | `owner` | FK → UserProfile | no (null)| `SET_NULL` on delete; `related_name="owner"` | -| `allowed_users` | M2M → UserProfile| — | Keepers; `related_name="keepers"` | +| `allowed_users` | M2M → UserProfile| — | Keepers; `through="AnimalShare"`; `related_name="keepers"` | | `first_contact_vet` | CharField(250) | no | | | `first_contact_medical_place`| CharField(250) | no | | | `last_control_visit` | DateTimeField | no | | @@ -44,6 +44,62 @@ This ADR is a living document — update it when new fields are added. **`TextChoices` placement** — defined at module level, above the model class that uses them. +#### `AnimalShare` model (`animals/models.py`) — through model for `Animal.allowed_users` + +Stores per-share metadata for the keeper relationship. Created explicitly via the +`create_share(animal, carer_id, scope, valid_until)` service; never via `.add()` in application code. + +| Field | Type | Notes | +|------------------|------------------|------------------------------------------------------------| +| `id` | AutoField (PK) | | +| `animal` | FK → Animal | `CASCADE`, `related_name="shares"` | +| `carer` | FK → UserProfile | `CASCADE`, `related_name="received_shares"` | +| `created` | DateTimeField | `auto_now_add`, records when share was granted | +| `valid_until` | DateField | `null=True` = indefinite; expiry enforced by selectors | +| `allow_basic` | BooleanField | Basic info (name, species, breed, sex, age, descriptions) | +| `allow_vet_contact` | BooleanField | Vet contact fields + `next_visit_date` | +| `allow_diet` | BooleanField | `dietary_restrictions` + diet-note timeline | +| `allow_medications` | BooleanField | Medication-note timeline | +| `allow_history` | BooleanField | Medical-visit timeline + general notes | +| `allow_biometrics` | BooleanField | Biometric records | + +**Unique constraint**: `(animal, carer)` — one share row per keeper per animal. + +**Helper methods**: +- `allowed_categories() -> set[str]` — maps boolean flags to `ShareCategory` values. +- `is_active(today) -> bool` — returns `True` when `valid_until is None or valid_until >= today`. + +**Access enforcement — two layers**: +1. `animals/selectors.py`: `user_can_access_animal` checks expiry; `allowed_categories_for` returns the granted set. +2. Tab views / templates: `_build_*` functions skip building data for absent categories; templates gate sections with `{% if "<cat>" in allowed_categories %}`. + +#### `ShareCategory(TextChoices)` (`animals/models.py`) + +| Value | Label | Scope | +|---------------|-------------------|--------------------------------------------------------| +| `basic` | Basic info | Hero + Overview tab (name, species, breed, sex, age…) | +| `vet_contact` | Vet contact | `first_contact_*`, `next_visit_date` (Vet tab fields) | +| `diet` | Diet | `dietary_restrictions` + diet-note timeline | +| `medications` | Medications | medicament-note timeline | +| `history` | History & notes | medical-visit timeline + fast/other notes | +| `biometrics` | Biometrics | biometric-record notes | + +#### `ShareDefaults` model (`animals/models.py`) + +Per-owner template applied automatically when a new share is created without an explicit scope. + +| Field | Type | Default | Notes | +|-------------------|------------------|---------|----------------------------------------------------| +| `profile` | OneToOne → UserProfile | — | `CASCADE`, `related_name="share_defaults"` | +| `allow_basic` | BooleanField | `True` | | +| `allow_vet_contact` | BooleanField | `False` | | +| `allow_diet` | BooleanField | `False` | | +| `allow_medications` | BooleanField | `False` | | +| `allow_history` | BooleanField | `False` | | +| `allow_biometrics` | BooleanField | `False` | | + +Created lazily via `get_or_create_share_defaults(profile)`. Editable at `/users/share-defaults/` (name `share_defaults`). + #### `UserProfile` model (`users/models.py`) Extends `auth.User` via OneToOne. Stores profile image, pinned animals (`M2M → Animal`). Full field list: see `users/models.py`. @@ -58,9 +114,11 @@ Core fields: `animal` (FK), `title`, `short_description`, `full_description`, `c - `Animal` fields are edited through the `Change*` pipeline documented in `CLAUDE.md` (Animals App — Conventions). - New fields on `Animal` require a migration named `0NNN_add_<field>.py` via `makemigrations animals --name`. - Optional fields must **not** be displayed in the hero overview when empty — use `{% if animal.field %}` guards. +- New keeper shares must be created via `services.create_share(...)`, never via `animal.allowed_users.add()` in application code (the through model would create rows with all `allow_*=False`). +- Expired shares (`valid_until < today`) are excluded by the selectors; no background cleanup is required for correctness, though a Celery Beat task could prune old rows. ### Keywords -- data, database, models, Animal, UserProfile, MedicalNote +- data, database, models, Animal, AnimalShare, ShareDefaults, ShareCategory, UserProfile, MedicalNote, sharing, privacy ### Links - `CLAUDE.md` — Animals App Conventions (field editing pipeline, model idioms) diff --git a/src/ahc/apps/animals/migrations/0004_add_animalshare_and_sharedefaults.py b/src/ahc/apps/animals/migrations/0004_add_animalshare_and_sharedefaults.py new file mode 100644 index 0000000..b54cf71 --- /dev/null +++ b/src/ahc/apps/animals/migrations/0004_add_animalshare_and_sharedefaults.py @@ -0,0 +1,118 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_existing_keepers(apps, schema_editor): + """Copy rows from the legacy auto M2M table into AnimalShare with full access. + + Existing keepers retain complete visibility so there is no silent data loss. + """ + with schema_editor.connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO animals_animalshare + (animal_id, carer_id, created, valid_until, + allow_basic, allow_vet_contact, allow_diet, + allow_medications, allow_history, allow_biometrics) + SELECT + animal_id, + profile_id, + CURRENT_TIMESTAMP, + NULL, + TRUE, TRUE, TRUE, TRUE, TRUE, TRUE + FROM animals_animal_allowed_users + """ + ) + + +def restore_legacy_keepers(apps, schema_editor): + """Reverse: copy AnimalShare rows back into the legacy M2M table.""" + with schema_editor.connection.cursor() as cursor: + cursor.execute( + "INSERT INTO animals_animal_allowed_users (animal_id, profile_id) " + "SELECT animal_id, carer_id FROM animals_animalshare" + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("animals", "0003_add_species_breed_sex_and_sterilization"), + ("users", "0003_profile_pinned_animals"), + ] + + operations = [ + migrations.CreateModel( + name="AnimalShare", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created", models.DateTimeField(auto_now_add=True)), + ("valid_until", models.DateField(blank=True, default=None, null=True)), + ("allow_basic", models.BooleanField(default=False)), + ("allow_vet_contact", models.BooleanField(default=False)), + ("allow_diet", models.BooleanField(default=False)), + ("allow_medications", models.BooleanField(default=False)), + ("allow_history", models.BooleanField(default=False)), + ("allow_biometrics", models.BooleanField(default=False)), + ( + "animal", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="shares", + to="animals.animal", + ), + ), + ( + "carer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="received_shares", + to="users.profile", + ), + ), + ], + options={"constraints": [models.UniqueConstraint(fields=["animal", "carer"], name="uniq_animal_carer_share")]}, + ), + migrations.CreateModel( + name="ShareDefaults", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("allow_basic", models.BooleanField(default=True)), + ("allow_vet_contact", models.BooleanField(default=False)), + ("allow_diet", models.BooleanField(default=False)), + ("allow_medications", models.BooleanField(default=False)), + ("allow_history", models.BooleanField(default=False)), + ("allow_biometrics", models.BooleanField(default=False)), + ( + "profile", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="share_defaults", + to="users.profile", + ), + ), + ], + ), + # Step 1: copy existing keeper pairs into AnimalShare before touching the old table. + migrations.RunPython(copy_existing_keepers, reverse_code=restore_legacy_keepers), + # Step 2: tell the ORM the field now uses a through model (state only), + # and physically drop the now-redundant auto M2M table (database only). + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunSQL( + sql="DROP TABLE animals_animal_allowed_users;", + reverse_sql=migrations.RunSQL.noop, + ), + ], + state_operations=[ + migrations.AlterField( + model_name="animal", + name="allowed_users", + field=models.ManyToManyField( + related_name="keepers", + through="animals.AnimalShare", + to="users.profile", + ), + ), + ], + ), + ] diff --git a/src/ahc/apps/animals/migrations/0005_add_vaccination_share_category.py b/src/ahc/apps/animals/migrations/0005_add_vaccination_share_category.py new file mode 100644 index 0000000..6cde747 --- /dev/null +++ b/src/ahc/apps/animals/migrations/0005_add_vaccination_share_category.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.5 on 2026-06-02 20:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("animals", "0004_add_animalshare_and_sharedefaults"), + ] + + operations = [ + migrations.AddField( + model_name="animalshare", + name="allow_vaccinations", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="sharedefaults", + name="allow_vaccinations", + field=models.BooleanField(default=False), + ), + ] diff --git a/src/ahc/apps/animals/models.py b/src/ahc/apps/animals/models.py index d1fcc0d..f6f6552 100644 --- a/src/ahc/apps/animals/models.py +++ b/src/ahc/apps/animals/models.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import uuid +from datetime import date from django.db import models @@ -10,6 +13,16 @@ class Sex(models.TextChoices): FEMALE = "f", "Female" +class ShareCategory(models.TextChoices): + BASIC = "basic", "Basic info" + VET_CONTACT = "vet_contact", "Vet contact" + DIET = "diet", "Diet" + MEDICATIONS = "medications", "Medications" + HISTORY = "history", "History & notes" + BIOMETRICS = "biometrics", "Biometrics" + VACCINATIONS = "vaccinations", "Vaccinations" + + class Animal(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) full_name = models.CharField(max_length=50, null=False, blank=False) @@ -23,7 +36,7 @@ class Animal(models.Model): owner = models.ForeignKey( UserProfile, on_delete=models.SET_NULL, null=True, related_name="owner" ) # dodac okresową notyfikację o braku ownera - przypisuje admin z panelu - allowed_users = models.ManyToManyField(UserProfile, related_name="keepers") + allowed_users = models.ManyToManyField(UserProfile, through="AnimalShare", related_name="keepers") first_contact_vet = models.CharField(max_length=250, default=None, blank=True, null=True) # first_contact_vet = models.ForeignKey(Vet_pofile) @@ -40,3 +53,55 @@ class Animal(models.Model): breed = models.CharField(max_length=100, default=None, blank=True, null=True) sex = models.CharField(max_length=1, choices=Sex.choices, default=None, blank=True, null=True) sterilization = models.BooleanField(default=False) + + +class AnimalShare(models.Model): + """Through model for Animal.allowed_users — stores per-share access scope and expiry.""" + + animal = models.ForeignKey(Animal, on_delete=models.CASCADE, related_name="shares") + carer = models.ForeignKey(UserProfile, on_delete=models.CASCADE, related_name="received_shares") + created = models.DateTimeField(auto_now_add=True, editable=False) + valid_until = models.DateField(default=None, blank=True, null=True) + + allow_basic = models.BooleanField(default=False) + allow_vet_contact = models.BooleanField(default=False) + allow_diet = models.BooleanField(default=False) + allow_medications = models.BooleanField(default=False) + allow_history = models.BooleanField(default=False) + allow_biometrics = models.BooleanField(default=False) + allow_vaccinations = models.BooleanField(default=False) + + class Meta: + constraints = [models.UniqueConstraint(fields=["animal", "carer"], name="uniq_animal_carer_share")] + + def allowed_categories(self) -> set[str]: + """Return the set of ShareCategory values this share grants access to.""" + mapping = { + "allow_basic": ShareCategory.BASIC, + "allow_vet_contact": ShareCategory.VET_CONTACT, + "allow_diet": ShareCategory.DIET, + "allow_medications": ShareCategory.MEDICATIONS, + "allow_history": ShareCategory.HISTORY, + "allow_biometrics": ShareCategory.BIOMETRICS, + "allow_vaccinations": ShareCategory.VACCINATIONS, + } + return {value for attr, value in mapping.items() if getattr(self, attr)} + + def is_active(self, today: date | None = None) -> bool: + """Return True if the share has not yet expired.""" + if today is None: + today = date.today() + return self.valid_until is None or self.valid_until >= today + + +class ShareDefaults(models.Model): + """Per-owner template that pre-fills the access scope when a new share is created.""" + + profile = models.OneToOneField(UserProfile, on_delete=models.CASCADE, related_name="share_defaults") + allow_basic = models.BooleanField(default=True) + allow_vet_contact = models.BooleanField(default=False) + allow_diet = models.BooleanField(default=False) + allow_medications = models.BooleanField(default=False) + allow_history = models.BooleanField(default=False) + allow_biometrics = models.BooleanField(default=False) + allow_vaccinations = models.BooleanField(default=False) diff --git a/src/ahc/apps/animals/selectors.py b/src/ahc/apps/animals/selectors.py index 306ee4e..38e2d7d 100644 --- a/src/ahc/apps/animals/selectors.py +++ b/src/ahc/apps/animals/selectors.py @@ -1,20 +1,45 @@ from __future__ import annotations +from datetime import date + from django.db.models import Q, QuerySet -from ahc.apps.animals.models import Animal +from ahc.apps.animals.models import Animal, AnimalShare, ShareCategory, ShareDefaults + + +def _today() -> date: + return date.today() def animals_visible_to(profile) -> QuerySet[Animal]: - """Return all animals accessible to the given profile (owner or keeper).""" - return Animal.objects.filter(Q(owner=profile) | Q(allowed_users=profile)).order_by("-creation_date") + """Return all animals accessible to the given profile (owner or active keeper).""" + today = _today() + return ( + Animal.objects.filter( + Q(owner=profile) + | Q(shares__carer=profile, shares__valid_until__isnull=True) + | Q(shares__carer=profile, shares__valid_until__gte=today) + ) + .distinct() + .order_by("-creation_date") + ) + + +def active_share_for(profile, animal: Animal) -> AnimalShare | None: + """Return the non-expired AnimalShare for this profile/animal pair, or None.""" + today = _today() + try: + share = AnimalShare.objects.get(animal=animal, carer=profile) + except AnimalShare.DoesNotExist: + return 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 one of the allowed_users of the animal.""" + """Return True if the profile is the owner or holds an active (non-expired) share.""" if animal.owner == profile: return True - return animal.allowed_users.filter(pk=profile.pk).exists() + return active_share_for(profile, animal) is not None def is_animal_owner(profile, animal: Animal) -> bool: @@ -22,6 +47,27 @@ def is_animal_owner(profile, animal: Animal) -> bool: return animal.owner == profile +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. + An empty set means no data-category access (animal page itself still blocked + upstream by user_can_access_animal). + """ + if is_animal_owner(profile, animal): + return {c.value for c in ShareCategory} + share = active_share_for(profile, animal) + if share is None: + return set() + return share.allowed_categories() + + +def get_or_create_share_defaults(profile) -> ShareDefaults: + """Return the owner's ShareDefaults row, creating it with safe defaults if absent.""" + defaults, _ = ShareDefaults.objects.get_or_create(profile=profile) + return defaults + + def is_pinned(profile, animal: Animal) -> bool: """Return True if the animal is currently pinned by the given profile.""" return profile.pinned_animals.filter(pk=animal.pk).exists() diff --git a/src/ahc/apps/animals/services.py b/src/ahc/apps/animals/services.py index 7116b8e..6269940 100644 --- a/src/ahc/apps/animals/services.py +++ b/src/ahc/apps/animals/services.py @@ -1,10 +1,12 @@ from __future__ import annotations +from datetime import date + from django.shortcuts import get_object_or_404 from PIL import Image -from ahc.apps.animals.models import Animal -from ahc.apps.animals.selectors import user_can_access_animal +from ahc.apps.animals.models import Animal, AnimalShare +from ahc.apps.animals.selectors import get_or_create_share_defaults, user_can_access_animal def create_animal(owner_profile, form) -> Animal: @@ -42,17 +44,51 @@ def process_profile_image(animal: Animal) -> None: def transfer_ownership(animal: Animal, new_owner, set_keeper: bool, requesting_profile) -> None: """Transfer animal ownership to new_owner. - If set_keeper is True, the previous owner (requesting_profile) is added to allowed_users. + If set_keeper is True, the previous owner (requesting_profile) is added as a carer + with an AnimalShare that mirrors their ShareDefaults. """ animal.owner = new_owner animal.save() if set_keeper: - animal.allowed_users.add(requesting_profile) + create_share(animal, requesting_profile.pk, scope=None, valid_until=None) + + +def create_share(animal: Animal, carer_id, scope: dict | None, valid_until: date | None) -> AnimalShare: + """Create (or update) an AnimalShare for the given carer. + + When scope is None, the access flags are copied from the animal owner's ShareDefaults. + scope, when provided, is a dict mapping allow_* field names to bool values. + """ + if scope is None: + defaults = get_or_create_share_defaults(animal.owner) + scope = { + "allow_basic": defaults.allow_basic, + "allow_vet_contact": defaults.allow_vet_contact, + "allow_diet": defaults.allow_diet, + "allow_medications": defaults.allow_medications, + "allow_history": defaults.allow_history, + "allow_biometrics": defaults.allow_biometrics, + } + + share, _ = AnimalShare.objects.update_or_create( + animal=animal, + carer_id=carer_id, + defaults={"valid_until": valid_until, **scope}, + ) + return share + + +def update_share(share: AnimalShare, scope: dict, valid_until: date | None) -> None: + """Update the access scope and expiry date of an existing AnimalShare.""" + for field, value in scope.items(): + setattr(share, field, value) + share.valid_until = valid_until + share.save() def add_keeper(animal: Animal, keeper_id) -> None: - """Add a keeper to the animal's allowed_users list by Profile PK.""" - animal.allowed_users.add(keeper_id) + """Add a keeper to the animal's shares using the owner's default scope.""" + create_share(animal, keeper_id, scope=None, valid_until=None) def set_birthday(animal: Animal, birthdate) -> None: @@ -92,5 +128,5 @@ def set_animal_details( def remove_keeper(animal: Animal, keeper_id) -> None: - """Remove a keeper from the animal's allowed_users list by Profile PK.""" - animal.allowed_users.remove(keeper_id) + """Remove a keeper from the animal's shares by Profile PK.""" + AnimalShare.objects.filter(animal=animal, carer_id=keeper_id).delete() 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 %} + <h2>Edit access for {{ carer_name }}</h2> + <form method="post"> + {% csrf_token %} + {% include "partials/form_fields.html" %} + <button type="submit">Save</button> + </form> + <p><a href="{% url 'animal_tab' pk=animal_id slug='ownership' %}">Return to ownership</a></p> +{% endblock %} diff --git a/src/ahc/apps/animals/templates/animals/manage_keepers.html b/src/ahc/apps/animals/templates/animals/manage_keepers.html index d702ca9..eee1e4f 100644 --- a/src/ahc/apps/animals/templates/animals/manage_keepers.html +++ b/src/ahc/apps/animals/templates/animals/manage_keepers.html @@ -1,33 +1,27 @@ {% extends "homepage/base.html" %} {% block content %} - <div class="content-section"> - <form method="POST"> - {% csrf_token %} - <fieldset> - <legend>Set a new keeper for {{ full_name }}:</legend> - <div> - {{ form.input_user.label_tag }} - {{ form.input_user }} - {% for error in form.input_user.errors %} - <small class="form-error">{{ error }}</small> - {% endfor %} - </div> - <button type="submit">Save</button> - </fieldset> - </form> - </div> - {% if allowed_users %} - <div class="content-section"> - <h4>Current Keepers:</h4> - <ul> - {% for user in allowed_users %} - <li>{{ user }}</li> - {% endfor %} - </ul> - </div> + <h2>Add a keeper for {{ full_name }}</h2> + <form method="post"> + {% csrf_token %} + <fieldset> + <legend>New keeper</legend> + {% include "partials/form_fields.html" %} + <button type="submit">Add keeper</button> + </fieldset> + </form> + + {% if shares %} + <h4>Current keepers</h4> + <ul> + {% for share in shares %} + <li> + {{ share.carer }} + {% if share.valid_until %} — expires {{ share.valid_until }}{% else %} — no expiry{% endif %} + </li> + {% endfor %} + </ul> {% endif %} + <br> - <small> - Return to <a href="{{ animal_url }}">Pet profile</a> or <a href="{% url 'animals_stable' %}">Stable</a> - </small> + <small>Return to <a href="{{ animal_url }}">Pet profile</a> or <a href="{% url 'animals_stable' %}">Stable</a></small> {% endblock %} diff --git a/src/ahc/apps/animals/templates/animals/tabs/_diet.html b/src/ahc/apps/animals/templates/animals/tabs/_diet.html index 1618010..6cb8f23 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/_diet.html +++ b/src/ahc/apps/animals/templates/animals/tabs/_diet.html @@ -3,6 +3,8 @@ <h3>Diet</h3> + {% if "diet" in allowed_categories %} + {% if animal.dietary_restrictions %} <div class="section"> <h4 class="button secondary outline section-header" role="button" tabindex="0">Expand: dietary restrictions & things to avoid</h4> @@ -44,4 +46,8 @@ <h4 class="button secondary outline section-header" role="button" tabindex="0">E <p>No diet notes recorded yet.</p> {% endif %} + {% else %} + <p>You do not have access to this section.</p> + {% endif %} + </section> diff --git a/src/ahc/apps/animals/templates/animals/tabs/_mainpage.html b/src/ahc/apps/animals/templates/animals/tabs/_mainpage.html index 6c2917a..40d34d4 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/_mainpage.html +++ b/src/ahc/apps/animals/templates/animals/tabs/_mainpage.html @@ -1,6 +1,7 @@ {% load static %} <section class="tab-panel"> + {% if "basic" in allowed_categories %} <div> <div class="section"> <h4 class="button secondary outline section-header" role="button" tabindex="0">Expand: additional description</h4> @@ -9,6 +10,7 @@ <h4 class="button secondary outline section-header" role="button" tabindex="0">E </div> </div> </div> + {% endif %} <br> <div class="grid"> diff --git a/src/ahc/apps/animals/templates/animals/tabs/_medications.html b/src/ahc/apps/animals/templates/animals/tabs/_medications.html index 802d7ba..5fb1577 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/_medications.html +++ b/src/ahc/apps/animals/templates/animals/tabs/_medications.html @@ -3,6 +3,8 @@ <h3>Medications</h3> + {% if "medications" in allowed_categories %} + <div class="records-actions"> <a role="button" class="secondary outline" href="{% url 'note_create' pk=animal.id %}?type_of_event=medicament_note" @@ -39,4 +41,8 @@ <h3>Medications</h3> <p>No medication notes recorded yet.</p> {% endif %} + {% else %} + <p>You do not have access to this section.</p> + {% endif %} + </section> diff --git a/src/ahc/apps/animals/templates/animals/tabs/_notes.html b/src/ahc/apps/animals/templates/animals/tabs/_notes.html index 077bb25..38de6ce 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/_notes.html +++ b/src/ahc/apps/animals/templates/animals/tabs/_notes.html @@ -1,6 +1,7 @@ {% load static %} <section class="tab-panel"> + {% if "history" in allowed_categories %} <h3>Notes</h3> <section class="timeline"> <div class="info"> @@ -70,5 +71,31 @@ <h3>Notes</h3> <p>No notes recorded yet.</p> {% endif %} </section> + {% endif %} + + {% if "biometrics" in allowed_categories %} + <h3>Biometrics</h3> + {% if biometric_records %} + <section class="timeline" style="grid-template-columns: 1fr;"> + <ol> + {% for record in biometric_records %} + <li> + <div> + <time>{{ record.date_creation|date:"Y-m-d" }}</time> + <a href="{% url 'note_edit' pk=record.id %}">{{ record.short_description }}</a> + </div> + </li> + {% endfor %} + <li></li> + </ol> + </section> + {% else %} + <p>No biometric records yet.</p> + {% endif %} + {% endif %} + + {% if not "history" in allowed_categories and not "biometrics" in allowed_categories %} + <p>You do not have access to this section.</p> + {% endif %} </section> diff --git a/src/ahc/apps/animals/templates/animals/tabs/_ownership.html b/src/ahc/apps/animals/templates/animals/tabs/_ownership.html index 3d70b2e..42d57e4 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/_ownership.html +++ b/src/ahc/apps/animals/templates/animals/tabs/_ownership.html @@ -9,13 +9,34 @@ <h3>Ownership</h3> <h4>Keepers</h4> {% if keepers %} <ul class="keeper-list"> - {% for keeper in keepers %} + {% for share in keepers %} <li class="keeper-list__item"> - <span>{{ keeper }}</span> - <form method="post" action="{% url 'remove_keeper' pk=animal.id keeper_pk=keeper.pk %}"> - {% csrf_token %} - <button type="submit" class="secondary outline">Remove</button> - </form> + <div class="keeper-list__info"> + <span class="keeper-list__name">{{ share.carer }}</span> + <span class="keeper-list__expiry"> + {% if share.valid_until %} + Expires: {{ share.valid_until }} + {% else %} + No expiry + {% endif %} + </span> + <ul class="keeper-list__categories"> + {% if share.allow_basic %}<li>Basic info</li>{% endif %} + {% if share.allow_vet_contact %}<li>Vet contact</li>{% endif %} + {% if share.allow_diet %}<li>Diet</li>{% endif %} + {% if share.allow_medications %}<li>Medications</li>{% endif %} + {% if share.allow_history %}<li>History & notes</li>{% endif %} + {% if share.allow_biometrics %}<li>Biometrics</li>{% endif %} + </ul> + </div> + <div class="keeper-list__actions"> + <a role="button" class="secondary outline" + href="{% url 'edit_share' pk=animal.id keeper_pk=share.carer.pk %}">Edit access</a> + <form method="post" action="{% url 'remove_keeper' pk=animal.id keeper_pk=share.carer.pk %}"> + {% csrf_token %} + <button type="submit" class="secondary outline">Remove</button> + </form> + </div> </li> {% endfor %} </ul> diff --git a/src/ahc/apps/animals/templates/animals/tabs/_vet.html b/src/ahc/apps/animals/templates/animals/tabs/_vet.html index c5ad027..f97fec6 100644 --- a/src/ahc/apps/animals/templates/animals/tabs/_vet.html +++ b/src/ahc/apps/animals/templates/animals/tabs/_vet.html @@ -1,6 +1,7 @@ {% load static %} <section class="tab-panel"> + {% if "vet_contact" in allowed_categories %} <h3>Veterinary contact</h3> <div class="section"> <h4 class="button secondary outline section-header" role="button" tabindex="0">Expand: first contact details</h4> @@ -22,8 +23,10 @@ <h4 class="button secondary outline section-header" role="button" tabindex="0">E <a role="button" class="secondary outline" href="{% url 'animal_next_visit' pk=animal.id %}">Set next visit date</a> </div> {% endif %} - <br> + {% endif %} + + {% if "history" in allowed_categories %} <h3>Medical visit timeline</h3> <section class="timeline"> <div class="info"> @@ -84,5 +87,10 @@ <h3>Medical visit timeline</h3> <p>No medical visits recorded yet.</p> {% endif %} </section> + {% endif %} + + {% if not "vet_contact" in allowed_categories and not "history" in allowed_categories %} + <p>You do not have access to this section.</p> + {% endif %} </section> diff --git a/src/ahc/apps/animals/tests.py b/src/ahc/apps/animals/tests.py index aab0722..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 @@ -322,12 +324,12 @@ def test_set_first_contact_assigns_both_fields_and_saves(self): class TestNewAnimalServices: """remove_keeper / set_next_visit / set_dietary_restrictions: unit coverage.""" - def test_remove_keeper_delegates_to_allowed_users(self): - from ahc.apps.animals.services import remove_keeper - + def test_remove_keeper_delegates_to_animalshare(self): animal = MagicMock() - remove_keeper(animal, 99) - animal.allowed_users.remove.assert_called_once_with(99) + 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 @@ -350,12 +352,11 @@ def test_set_dietary_restrictions_assigns_text_and_saves(self): def test_remove_keeper_does_not_affect_owner(self): """Removing a keeper must not touch the owner field.""" - from ahc.apps.animals.services import remove_keeper - animal = MagicMock() original_owner = MagicMock() animal.owner = original_owner - remove_keeper(animal, 42) + with patch("ahc.apps.animals.services.AnimalShare"): + remove_keeper(animal, 42) assert animal.owner is original_owner diff --git a/src/ahc/apps/animals/urls.py b/src/ahc/apps/animals/urls.py index 0f6e6de..c9fb55b 100644 --- a/src/ahc/apps/animals/urls.py +++ b/src/ahc/apps/animals/urls.py @@ -21,6 +21,7 @@ ), path("<uuid:pk>/details/", animal_owner_views.ChangeAnimalDetailsView.as_view(), name="animal_details"), path("<uuid:pk>/keepers/<int:keeper_pk>/remove/", animal_owner_views.RemoveKeeperView.as_view(), name="remove_keeper"), + path("<uuid:pk>/keepers/<int:keeper_pk>/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 af9b1f0..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,16 +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.") profile = Profile.objects.filter(user__username=input_user).first() if profile is None: raise forms.ValidationError("User does not exist.") - input_user_id = profile.pk + 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): diff --git a/src/ahc/apps/animals/utils_owner/views.py b/src/ahc/apps/animals/utils_owner/views.py index f12eb63..1e01b20 100644 --- a/src/ahc/apps/animals/utils_owner/views.py +++ b/src/ahc/apps/animals/utils_owner/views.py @@ -5,9 +5,9 @@ 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, @@ -16,6 +16,7 @@ set_first_contact, set_next_visit, transfer_ownership, + update_share, ) from ahc.apps.animals.utils_owner.forms import ( ChangeAnimalDetailsForm, @@ -24,6 +25,7 @@ ChangeFirstContactForm, ChangeNextVisitForm, ChangeOwnerForm, + EditShareForm, ImageUploadForm, ManageKeepersForm, ) @@ -99,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 @@ -109,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): @@ -228,9 +239,45 @@ def form_valid(self, form): class RemoveKeeperView(LoginRequiredMixin, UserPassesOwnershipTestMixin, View): - """Remove a single keeper from the animal's allowed_users (owner-only, POST).""" + """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/homepage/templates/homepage/base.html b/src/ahc/apps/homepage/templates/homepage/base.html index 4dc1604..a3bd83f 100644 --- a/src/ahc/apps/homepage/templates/homepage/base.html +++ b/src/ahc/apps/homepage/templates/homepage/base.html @@ -63,7 +63,12 @@ <ul> {% if user.is_authenticated %} <li><a role="button" class="secondary outline" href="{% url 'profile' %}">Profile</a></li> - <li><a role="button" class="secondary outline" href="{% url 'logout' %}">Logout</a></li> + <li> + <form method="post" action="{% url 'logout' %}" style="margin:0"> + {% csrf_token %} + <button type="submit" class="secondary outline">Logout</button> + </form> + </li> {% else %} <li><a role="button" class="secondary outline" href="{% url 'login' %}">Login</a></li> <li><a role="button" class="secondary outline" href="{% url 'register' %}">Register</a></li> diff --git a/src/ahc/apps/users/forms.py b/src/ahc/apps/users/forms.py index f97d2de..a8f598e 100644 --- a/src/ahc/apps/users/forms.py +++ b/src/ahc/apps/users/forms.py @@ -2,6 +2,7 @@ from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.models import User +from ahc.apps.animals.models import ShareDefaults from ahc.apps.users.models import Profile @@ -25,3 +26,26 @@ class ProfileUpdateForm(forms.ModelForm): class Meta: model = Profile fields = ["profile_image"] + + +class ShareDefaultsForm(forms.ModelForm): + class Meta: + model = ShareDefaults + fields = [ + "allow_basic", + "allow_vet_contact", + "allow_diet", + "allow_medications", + "allow_history", + "allow_biometrics", + "allow_vaccinations", + ] + labels = { + "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", + } diff --git a/src/ahc/apps/users/templates/users/share_defaults.html b/src/ahc/apps/users/templates/users/share_defaults.html new file mode 100644 index 0000000..cd66229 --- /dev/null +++ b/src/ahc/apps/users/templates/users/share_defaults.html @@ -0,0 +1,18 @@ +{% extends "homepage/base.html" %} +{% block content %} + <h2>Default sharing settings</h2> + <p>These settings are applied automatically when you add a new keeper to one of your animals. + You can still override them per-share when adding or editing a keeper.</p> + + <form method="post"> + {% csrf_token %} + {% include "partials/form_fields.html" %} + <button type="submit">Save defaults</button> + </form> + + {% if messages %} + {% for message in messages %} + <p>{{ message }}</p> + {% endfor %} + {% endif %} +{% endblock %} diff --git a/src/ahc/apps/users/urls.py b/src/ahc/apps/users/urls.py index 33b0792..9e4785f 100644 --- a/src/ahc/apps/users/urls.py +++ b/src/ahc/apps/users/urls.py @@ -10,6 +10,7 @@ from ahc.apps.users import views as user_views urlpatterns = [ + path("share-defaults/", user_views.ShareDefaultsView.as_view(), name="share_defaults"), path("", auth_views.LoginView.as_view(template_name="users/login.html"), name="login"), path("login/", auth_views.LoginView.as_view(template_name="users/login.html"), name="login"), path("register/", user_views.UserRegisterView.as_view(), name="register"), diff --git a/src/ahc/apps/users/views.py b/src/ahc/apps/users/views.py index bb7e5c1..9b955be 100644 --- a/src/ahc/apps/users/views.py +++ b/src/ahc/apps/users/views.py @@ -1,9 +1,11 @@ from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.urls import reverse_lazy +from django.shortcuts import redirect +from django.urls import reverse, reverse_lazy from django.views.generic import CreateView, UpdateView +from django.views.generic.edit import FormView -from ahc.apps.users.forms import ProfileUpdateForm, UserRegisterForm, UserUpdateForm +from ahc.apps.users.forms import ProfileUpdateForm, ShareDefaultsForm, UserRegisterForm, UserUpdateForm from ahc.apps.users.models import Profile @@ -37,3 +39,22 @@ def form_valid(self, form): response = super().form_valid(form) messages.success(self.request, "Your profile has been updated") return response + + +class ShareDefaultsView(LoginRequiredMixin, FormView): + """Let the owner configure their default share scope for new keepers.""" + + template_name = "users/share_defaults.html" + form_class = ShareDefaultsForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + from ahc.apps.animals.selectors import get_or_create_share_defaults + + kwargs["instance"] = get_or_create_share_defaults(self.request.user.profile) + return kwargs + + def form_valid(self, form): + form.save() + messages.success(self.request, "Default share settings saved.") + return redirect(reverse("share_defaults")) diff --git a/templates/partials/form_fields.html b/templates/partials/form_fields.html index eb40be8..7a27877 100644 --- a/templates/partials/form_fields.html +++ b/templates/partials/form_fields.html @@ -1,3 +1,10 @@ +{% if form.non_field_errors %} +<div id="field_non_field_errors"> + {% for error in form.non_field_errors %} + <small class="form-error">{{ error }}</small> + {% endfor %} +</div> +{% endif %} {% for field in form %} <div id="field_{{ field.html_name }}"> {{ field.label_tag }} From 10c3ca0c45a34a557d28d5a28484ad4bad353cff Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:21:40 +0200 Subject: [PATCH 13/15] feat(vaccination): vaccination note type with inline-editable tab --- .../animals/templates/animals/profile.html | 1 + .../templates/animals/tabs/_vaccinations.html | 40 +++ src/ahc/apps/animals/views.py | 258 ++++++++++++------ .../forms/type_vaccination_notes.py | 35 +++ .../migrations/0016_add_vaccination_note.py | 35 +++ .../models/type_vaccination_notes.py | 30 ++ src/ahc/apps/medical_notes/selectors.py | 64 ++++- .../medical_notes/services/vaccinations.py | 68 +++++ .../partials/_vaccination_row.html | 20 ++ .../partials/_vaccination_row_form.html | 47 ++++ src/ahc/apps/medical_notes/tests.py | 208 ++++++++++++++ src/ahc/apps/medical_notes/urls.py | 14 + .../views/type_vaccination_notes.py | 143 ++++++++++ static/js/tabs.js | 3 + static/js/vaccination_table.js | 64 +++++ 15 files changed, 946 insertions(+), 84 deletions(-) create mode 100644 src/ahc/apps/animals/templates/animals/tabs/_vaccinations.html create mode 100644 src/ahc/apps/medical_notes/forms/type_vaccination_notes.py create mode 100644 src/ahc/apps/medical_notes/migrations/0016_add_vaccination_note.py create mode 100644 src/ahc/apps/medical_notes/models/type_vaccination_notes.py create mode 100644 src/ahc/apps/medical_notes/services/vaccinations.py create mode 100644 src/ahc/apps/medical_notes/templates/medical_notes/partials/_vaccination_row.html create mode 100644 src/ahc/apps/medical_notes/templates/medical_notes/partials/_vaccination_row_form.html create mode 100644 src/ahc/apps/medical_notes/views/type_vaccination_notes.py create mode 100644 static/js/vaccination_table.js diff --git a/src/ahc/apps/animals/templates/animals/profile.html b/src/ahc/apps/animals/templates/animals/profile.html index 5be9476..6482dd2 100644 --- a/src/ahc/apps/animals/templates/animals/profile.html +++ b/src/ahc/apps/animals/templates/animals/profile.html @@ -11,6 +11,7 @@ <script defer src="{% static 'js/expanding_sections.js' %}"></script> <script defer src="{% static 'js/timeline.js' %}"></script> <script defer src="{% static 'js/pin_animal.js' %}"></script> + <script defer src="{% static 'js/vaccination_table.js' %}"></script> {% endblock %} {% block tab_nav %} 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 %} +<section class="tab-panel"> + + <h3>Vaccinations</h3> + + {% if "vaccinations" in allowed_categories %} + + <div style="overflow-x: auto;"> + <table id="vaccination-table"> + <thead> + <tr> + <th data-sort="text" data-label="Vaccine name">Vaccine name</th> + <th data-sort="date" data-label="Last vaccinated">Last vaccinated</th> + <th data-sort="date" data-label="Valid until" data-default-sort="asc">Valid until</th> + <th data-sort="text" data-label="Suggested clinic">Suggested clinic</th> + <th data-sort="date" data-label="Reminder date">Reminder date</th> + <th>Actions</th> + </tr> + </thead> + <tbody id="vaccination-rows"> + {% for vaccination in vaccination_records %} + {% include "medical_notes/partials/_vaccination_row.html" %} + {% endfor %} + </tbody> + </table> + </div> + + <div class="records-actions"> + <a role="button" class="secondary outline" + hx-get="{% url 'vaccination_add' pk=animal.id %}" + hx-target="#vaccination-rows" + hx-swap="beforeend" + href="{% url 'vaccination_add' pk=animal.id %}">Add vaccination</a> + </div> + + {% else %} + <p>You do not have access to this section.</p> + {% endif %} + +</section> diff --git a/src/ahc/apps/animals/views.py b/src/ahc/apps/animals/views.py index 453c651..85ad176 100644 --- a/src/ahc/apps/animals/views.py +++ b/src/ahc/apps/animals/views.py @@ -17,6 +17,7 @@ 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, @@ -32,9 +33,10 @@ class Tab: template: str owner_only: bool build: Callable[..., dict[str, Any]] + categories: frozenset[str] = frozenset() -def _build_mainpage(request, animal: Animal) -> dict[str, Any]: +def _build_mainpage(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]: return {} @@ -56,39 +58,49 @@ def _timeline_boundary_from_month(month_param: str) -> datetime | None: 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) -> dict[str, Any]: - 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] - - return { - "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"), - } +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) -> dict[str, Any]: +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 { @@ -96,69 +108,134 @@ def _build_diet(request, animal: Animal) -> dict[str, Any]: } -def _build_medications(request, animal: Animal) -> dict[str, Any]: +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) -> dict[str, Any]: - from ahc.apps.medical_notes.selectors import other_records_for +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_records_for(animal) + qs = other_history_for(animal) - month_param = request.GET.get("month") - before_param = request.GET.get("before") + 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) + 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] + 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_records_for(animal).datetimes( - "date_creation", - "month", - order="DESC", - tzinfo=timezone.get_current_timezone(), + available_months = list( + other_history_for(animal).datetimes( + "date_creation", + "month", + order="DESC", + tzinfo=timezone.get_current_timezone(), + ) ) - ) - return { - "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, - } + 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) -> dict[str, Any]: - return {"keepers": animal.allowed_users.all()} +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) -> dict[str, Any]: + +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), - Tab("vet", "Vet & Visits", "animals/tabs/_vet.html", False, _build_vet), - Tab("diet", "Diet", "animals/tabs/_diet.html", False, _build_diet), - Tab("medications", "Medications", "animals/tabs/_medications.html", False, _build_medications), - Tab("notes", "Notes", "animals/tabs/_notes.html", False, _build_notes), + 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), ] @@ -173,12 +250,21 @@ 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), - # Non-owners do not see owner-only tabs in the nav bar. - "tabs": [t for t in TABS_LIST if not t.owner_only or owner], + "allowed_categories": allowed, + "tabs": [t for t in TABS_LIST if _tab_visible(t)], } @@ -206,10 +292,11 @@ class AnimalProfileDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailVie def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context.update(_base_profile_context(self.request, 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)) + context.update(TAB_REGISTRY[DEFAULT_TAB_SLUG].build(self.request, self.object, allowed=base["allowed_categories"])) return context def test_func(self): @@ -237,11 +324,17 @@ def _get_tab(self) -> Tab: def test_func(self): animal = self.get_object() - if not user_can_access_animal(self.request.user.profile, animal): + 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 and tab.owner_only: - return is_animal_owner(self.request.user.profile, animal) + 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): @@ -255,10 +348,11 @@ def get_template_names(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) tab = self._get_tab() - context.update(_base_profile_context(self.request, self.object)) + 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)) + context.update(tab.build(self.request, self.object, allowed=base["allowed_categories"])) return context diff --git a/src/ahc/apps/medical_notes/forms/type_vaccination_notes.py b/src/ahc/apps/medical_notes/forms/type_vaccination_notes.py new file mode 100644 index 0000000..1b0230f --- /dev/null +++ b/src/ahc/apps/medical_notes/forms/type_vaccination_notes.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from django import forms + +from ahc.apps.medical_notes.models.type_vaccination_notes import VaccinationNote + + +class VaccinationNoteForm(forms.ModelForm): + """Form for creating and editing a VaccinationNote. + + Used by the inline click-to-edit table rows on the Vaccinations tab. + Date fields use the HTML5 date picker to match the rest of the app. + """ + + class Meta: + model = VaccinationNote + fields = [ + "vaccine_name", + "last_vaccination_date", + "valid_until", + "suggested_clinic", + "reminder_date", + ] + widgets = { + "last_vaccination_date": forms.DateInput(attrs={"type": "date"}), + "valid_until": forms.DateInput(attrs={"type": "date"}), + "reminder_date": forms.DateInput(attrs={"type": "date"}), + } + labels = { + "vaccine_name": "Vaccine name", + "last_vaccination_date": "Last vaccinated", + "valid_until": "Valid until", + "suggested_clinic": "Suggested clinic", + "reminder_date": "Remind me on", + } diff --git a/src/ahc/apps/medical_notes/migrations/0016_add_vaccination_note.py b/src/ahc/apps/medical_notes/migrations/0016_add_vaccination_note.py new file mode 100644 index 0000000..625ca30 --- /dev/null +++ b/src/ahc/apps/medical_notes/migrations/0016_add_vaccination_note.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.5 on 2026-06-02 20:37 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("medical_notes", "0015_add_feeding_note_purchase_source"), + ] + + operations = [ + migrations.CreateModel( + name="VaccinationNote", + fields=[ + ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ("vaccine_name", models.CharField(max_length=120)), + ("last_vaccination_date", models.DateField(blank=True, default=None, null=True)), + ("valid_until", models.DateField(blank=True, default=None, null=True)), + ("suggested_clinic", models.CharField(blank=True, default="", max_length=250)), + ("reminder_date", models.DateField(blank=True, db_index=True, default=None, null=True)), + ("reminder_sent", models.BooleanField(default=False)), + ( + "related_note", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="vaccination_records", + to="medical_notes.medicalrecord", + ), + ), + ], + ), + ] diff --git a/src/ahc/apps/medical_notes/models/type_vaccination_notes.py b/src/ahc/apps/medical_notes/models/type_vaccination_notes.py new file mode 100644 index 0000000..02cc46f --- /dev/null +++ b/src/ahc/apps/medical_notes/models/type_vaccination_notes.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import uuid + +from django.db import models + +from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord + + +class VaccinationNote(models.Model): + """Satellite model for vaccination records. + + Each instance is linked to a MedicalRecord shell with + type_of_event="vaccination_note". The shell provides the common + medical timeline entry; this model holds vaccination-specific fields. + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + related_note = models.ForeignKey(MedicalRecord, on_delete=models.CASCADE, related_name="vaccination_records") + + vaccine_name = models.CharField(max_length=120) + last_vaccination_date = models.DateField(null=True, blank=True, default=None) + valid_until = models.DateField(null=True, blank=True, default=None) + suggested_clinic = models.CharField(max_length=250, blank=True, default="") + + reminder_date = models.DateField(null=True, blank=True, default=None, db_index=True) + reminder_sent = models.BooleanField(default=False) + + def __str__(self) -> str: + return self.vaccine_name diff --git a/src/ahc/apps/medical_notes/selectors.py b/src/ahc/apps/medical_notes/selectors.py index bb3758b..df1cd15 100644 --- a/src/ahc/apps/medical_notes/selectors.py +++ b/src/ahc/apps/medical_notes/selectors.py @@ -140,12 +140,44 @@ def medication_notes_for(animal) -> QuerySet[MedicalRecord]: def other_records_for(animal) -> QuerySet[MedicalRecord]: """Return MedicalRecords for an animal, excluding types shown on specialised tabs. - Excludes medical_visit (Vet), diet_note (Diet), and medicament_note (Medications). + Excludes medical_visit (Vet), diet_note (Diet), medicament_note (Medications), + and vaccination_note (Vaccinations). Results are prefetch_related for attachments and ordered newest first. """ return ( MedicalRecord.objects.filter(animal=animal) - .exclude(type_of_event__in=["medical_visit", "diet_note", "medicament_note"]) + .exclude(type_of_event__in=["medical_visit", "diet_note", "medicament_note", "vaccination_note"]) + .prefetch_related("attachments") + .order_by("-date_creation") + ) + + +def other_history_for(animal) -> QuerySet[MedicalRecord]: + """Return general (non-biometric) MedicalRecords for the Notes tab history section. + + Covers fast_note and other_user_note; excludes biometric_record, medical_visit, + diet_note, medicament_note, and vaccination_note. + """ + return ( + MedicalRecord.objects.filter(animal=animal) + .exclude( + type_of_event__in=[ + "medical_visit", + "diet_note", + "medicament_note", + "biometric_record", + "vaccination_note", + ] + ) + .prefetch_related("attachments") + .order_by("-date_creation") + ) + + +def biometric_records_for(animal) -> QuerySet[MedicalRecord]: + """Return biometric_record MedicalRecords for the Notes tab biometrics section.""" + return ( + MedicalRecord.objects.filter(animal=animal, type_of_event="biometric_record") .prefetch_related("attachments") .order_by("-date_creation") ) @@ -164,3 +196,31 @@ def is_attachment_author(profile, attachment: MedicalRecordAttachment) -> bool: def can_access_note_animal(profile, note: MedicalRecord) -> bool: """Return True if the profile is owner or keeper of the animal linked to the note.""" return user_can_access_animal(profile, note.animal) + + +def vaccination_notes_for(animal) -> QuerySet: + """Return VaccinationNotes for an animal, ordered by valid_until (soonest first). + + Records without a valid_until date are placed last. + """ + from ahc.apps.medical_notes.models.type_vaccination_notes import VaccinationNote + + return ( + VaccinationNote.objects.filter(related_note__animal=animal) + .select_related("related_note") + .order_by("valid_until", "reminder_date") + ) + + +def due_vaccination_reminders(on_date: date) -> QuerySet: + """Return VaccinationNotes whose reminder_date is today or overdue and not yet sent. + + Used by the daily Celery Beat task to dispatch Discord notifications. + """ + from ahc.apps.medical_notes.models.type_vaccination_notes import VaccinationNote + + return ( + VaccinationNote.objects.filter(reminder_date__lte=on_date, reminder_sent=False) + .select_related("related_note__animal__owner__user") + .exclude(reminder_date=None) + ) diff --git a/src/ahc/apps/medical_notes/services/vaccinations.py b/src/ahc/apps/medical_notes/services/vaccinations.py new file mode 100644 index 0000000..4c69d42 --- /dev/null +++ b/src/ahc/apps/medical_notes/services/vaccinations.py @@ -0,0 +1,68 @@ +"""Services for VaccinationNote creation, update, and deletion. + +Each vaccination note is composed of two rows: +- A MedicalRecord shell (type_of_event="vaccination_note") that places the + record on the common medical timeline. +- A VaccinationNote satellite that holds vaccination-specific fields. + +Deleting a VaccinationNote also removes its shell via this service +(the shell is not useful without the satellite). +""" + +from __future__ import annotations + +from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord +from ahc.apps.medical_notes.models.type_vaccination_notes import VaccinationNote + + +def create_vaccination_note(author_profile, animal, form) -> VaccinationNote: + """Create a MedicalRecord shell and a linked VaccinationNote from a validated form.""" + vaccine_name: str = form.cleaned_data["vaccine_name"] + + shell = MedicalRecord.objects.create( + animal=animal, + author=author_profile, + type_of_event="vaccination_note", + short_description=vaccine_name, + ) + + vaccination: VaccinationNote = form.save(commit=False) + vaccination.related_note = shell + vaccination.save() + return vaccination + + +def update_vaccination_note(vaccination: VaccinationNote, form) -> VaccinationNote: + """Apply validated form data to an existing VaccinationNote. + + Synchronises the shell's short_description when vaccine_name changes. + Preserves reminder_sent — it is reset to False only when reminder_date changes, + so the daily cron can re-send if the owner reschedules. + """ + old_reminder_date = vaccination.reminder_date + new_reminder_date = form.cleaned_data.get("reminder_date") + + updated: VaccinationNote = form.save(commit=False) + updated.pk = vaccination.pk + updated.related_note = vaccination.related_note + updated.id = vaccination.id + + if old_reminder_date != new_reminder_date: + updated.reminder_sent = False + else: + updated.reminder_sent = vaccination.reminder_sent + + new_vaccine_name: str = form.cleaned_data["vaccine_name"] + if new_vaccine_name != vaccination.related_note.short_description: + vaccination.related_note.short_description = new_vaccine_name + vaccination.related_note.save(update_fields=["short_description"]) + + updated.save() + return updated + + +def delete_vaccination_note(vaccination: VaccinationNote) -> None: + """Delete the satellite and its MedicalRecord shell.""" + shell: MedicalRecord = vaccination.related_note + vaccination.delete() + shell.delete() diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/partials/_vaccination_row.html b/src/ahc/apps/medical_notes/templates/medical_notes/partials/_vaccination_row.html new file mode 100644 index 0000000..57e2e7d --- /dev/null +++ b/src/ahc/apps/medical_notes/templates/medical_notes/partials/_vaccination_row.html @@ -0,0 +1,20 @@ +<tr id="vaccination-row-{{ vaccination.id }}"> + <td>{{ vaccination.vaccine_name }}</td> + <td>{{ vaccination.last_vaccination_date|date:"Y-m-d"|default:"—" }}</td> + <td>{{ vaccination.valid_until|date:"Y-m-d"|default:"—" }}</td> + <td>{{ vaccination.suggested_clinic|default:"—" }}</td> + <td>{{ vaccination.reminder_date|date:"Y-m-d"|default:"—" }}</td> + <td> + <a role="button" class="secondary outline" + hx-get="{% url 'vaccination_edit' vacc_id=vaccination.id %}" + hx-target="#vaccination-row-{{ vaccination.id }}" + hx-swap="outerHTML" + href="{% url 'vaccination_edit' vacc_id=vaccination.id %}">Edit</a> + <a role="button" class="secondary outline" + hx-post="{% url 'vaccination_delete' vacc_id=vaccination.id %}" + hx-target="#vaccination-row-{{ vaccination.id }}" + hx-swap="outerHTML" + hx-confirm="Delete this vaccination record?" + href="#">Delete</a> + </td> +</tr> diff --git a/src/ahc/apps/medical_notes/templates/medical_notes/partials/_vaccination_row_form.html b/src/ahc/apps/medical_notes/templates/medical_notes/partials/_vaccination_row_form.html new file mode 100644 index 0000000..cfd7fe8 --- /dev/null +++ b/src/ahc/apps/medical_notes/templates/medical_notes/partials/_vaccination_row_form.html @@ -0,0 +1,47 @@ +{% if is_new %} +<tr id="vaccination-row-new"> +{% else %} +<tr id="vaccination-row-{{ vaccination.id }}"> +{% endif %} + {% csrf_token %} + <td> + {{ form.vaccine_name }} + {% if form.vaccine_name.errors %}<small class="error">{{ form.vaccine_name.errors|join:" " }}</small>{% endif %} + </td> + <td> + {{ form.last_vaccination_date }} + {% if form.last_vaccination_date.errors %}<small class="error">{{ form.last_vaccination_date.errors|join:" " }}</small>{% endif %} + </td> + <td> + {{ form.valid_until }} + {% if form.valid_until.errors %}<small class="error">{{ form.valid_until.errors|join:" " }}</small>{% endif %} + </td> + <td> + {{ form.suggested_clinic }} + {% if form.suggested_clinic.errors %}<small class="error">{{ form.suggested_clinic.errors|join:" " }}</small>{% endif %} + </td> + <td> + {{ form.reminder_date }} + {% if form.reminder_date.errors %}<small class="error">{{ form.reminder_date.errors|join:" " }}</small>{% endif %} + </td> + <td> + {% if is_new %} + <button + hx-post="{% url 'vaccination_add' pk=animal_id %}" + hx-include="closest tr" + hx-target="#vaccination-row-new" + hx-swap="outerHTML">Save</button> + {% else %} + <button + hx-post="{% url 'vaccination_save' vacc_id=vaccination.id %}" + hx-include="closest tr" + hx-target="#vaccination-row-{{ vaccination.id }}" + hx-swap="outerHTML">Save</button> + <a role="button" class="secondary outline" + hx-get="{% url 'vaccination_cancel' vacc_id=vaccination.id %}" + hx-target="#vaccination-row-{{ vaccination.id }}" + hx-swap="outerHTML" + href="#">Cancel</a> + {% endif %} + </td> +</tr> diff --git a/src/ahc/apps/medical_notes/tests.py b/src/ahc/apps/medical_notes/tests.py index 2258565..f503876 100644 --- a/src/ahc/apps/medical_notes/tests.py +++ b/src/ahc/apps/medical_notes/tests.py @@ -427,3 +427,211 @@ def test_creates_custom_record(self, medical_note): assert record.custom_biometric_record.record_name == "Temperature" assert record.weight_biometric_record is None assert record.height_biometric_record is None + + +@pytest.mark.unit +class TestCreateVaccinationNoteService: + """create_vaccination_note: creates a MedicalRecord shell and a linked VaccinationNote.""" + + def test_creates_shell_with_correct_type(self): + from ahc.apps.medical_notes.services.vaccinations import create_vaccination_note + + author = MagicMock() + animal = MagicMock() + form = MagicMock() + form.cleaned_data = {"vaccine_name": "Rabies"} + + vacc_instance = MagicMock() + form.save.return_value = vacc_instance + + with patch("ahc.apps.medical_notes.services.vaccinations.MedicalRecord") as MockRecord: + shell_instance = MagicMock() + MockRecord.objects.create.return_value = shell_instance + + result = create_vaccination_note(author, animal, form) + + MockRecord.objects.create.assert_called_once_with( + animal=animal, + author=author, + type_of_event="vaccination_note", + short_description="Rabies", + ) + assert vacc_instance.related_note is shell_instance + vacc_instance.save.assert_called_once() + assert result is vacc_instance + + def test_update_resets_reminder_sent_when_date_changes(self): + from datetime import date + + from ahc.apps.medical_notes.services.vaccinations import update_vaccination_note + + vaccination = MagicMock() + vaccination.reminder_date = date(2026, 1, 1) + vaccination.reminder_sent = True + vaccination.related_note.short_description = "Rabies" + + form = MagicMock() + form.save.return_value = MagicMock() + form.cleaned_data = { + "vaccine_name": "Rabies", + "reminder_date": date(2026, 6, 1), + } + + result = update_vaccination_note(vaccination, form) + assert result.reminder_sent is False + + def test_update_preserves_reminder_sent_when_date_unchanged(self): + from datetime import date + + from ahc.apps.medical_notes.services.vaccinations import update_vaccination_note + + vaccination = MagicMock() + vaccination.reminder_date = date(2026, 6, 1) + vaccination.reminder_sent = True + vaccination.related_note.short_description = "Rabies" + + form = MagicMock() + form.save.return_value = MagicMock() + form.cleaned_data = { + "vaccine_name": "Rabies", + "reminder_date": date(2026, 6, 1), + } + + result = update_vaccination_note(vaccination, form) + assert result.reminder_sent is True + + def test_delete_removes_satellite_and_shell(self): + from ahc.apps.medical_notes.services.vaccinations import delete_vaccination_note + + vaccination = MagicMock() + shell = MagicMock() + vaccination.related_note = shell + + delete_vaccination_note(vaccination) + + vaccination.delete.assert_called_once() + shell.delete.assert_called_once() + + +@pytest.mark.unit +class TestVaccinationSelectors: + """due_vaccination_reminders: pure filtering logic verified with MagicMock.""" + + def test_other_history_for_excludes_vaccination_note(self): + from unittest.mock import patch + + from ahc.apps.medical_notes.selectors import other_history_for + + animal = MagicMock() + with patch("ahc.apps.medical_notes.selectors.MedicalRecord") as MockRecord: + qs = MagicMock() + MockRecord.objects.filter.return_value = qs + qs.exclude.return_value = qs + qs.prefetch_related.return_value = qs + qs.order_by.return_value = qs + + other_history_for(animal) + + exclude_call = qs.exclude.call_args + excluded_types = exclude_call[1]["type_of_event__in"] + assert "vaccination_note" in excluded_types + + def test_other_records_for_excludes_vaccination_note(self): + from ahc.apps.medical_notes.selectors import other_records_for + + animal = MagicMock() + with patch("ahc.apps.medical_notes.selectors.MedicalRecord") as MockRecord: + qs = MagicMock() + MockRecord.objects.filter.return_value = qs + qs.exclude.return_value = qs + qs.prefetch_related.return_value = qs + qs.order_by.return_value = qs + + other_records_for(animal) + + exclude_call = qs.exclude.call_args + excluded_types = exclude_call[1]["type_of_event__in"] + assert "vaccination_note" in excluded_types + + +@pytest.fixture +def vaccination_animal(db, user_profile): + from ahc.apps.animals.models import Animal + + _, profile = user_profile + return Animal.objects.create(full_name="VaccTest", owner=profile), profile + + +@pytest.mark.integration +@pytest.mark.django_db +class TestVaccinationNoteIntegration: + """End-to-end create / update / delete through services with a real SQLite DB.""" + + def test_create_builds_shell_and_satellite(self, vaccination_animal): + from datetime import date + + from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord + from ahc.apps.medical_notes.models.type_vaccination_notes import VaccinationNote + from ahc.apps.medical_notes.services.vaccinations import create_vaccination_note + + animal, profile = vaccination_animal + form = MagicMock() + form.cleaned_data = {"vaccine_name": "Distemper", "reminder_date": None} + vacc_mock = VaccinationNote( + vaccine_name="Distemper", + last_vaccination_date=date(2025, 1, 10), + valid_until=date(2026, 1, 10), + suggested_clinic="Happy Paws", + reminder_date=None, + ) + form.save.return_value = vacc_mock + + vaccination = create_vaccination_note(profile, animal, form) + + assert vaccination.related_note is not None + assert vaccination.related_note.type_of_event == "vaccination_note" + assert vaccination.related_note.short_description == "Distemper" + assert MedicalRecord.objects.filter(type_of_event="vaccination_note", animal=animal).count() == 1 + + def test_due_vaccination_reminders_returns_overdue_records(self, vaccination_animal): + from datetime import date + + from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord + from ahc.apps.medical_notes.models.type_vaccination_notes import VaccinationNote + from ahc.apps.medical_notes.selectors import due_vaccination_reminders + + animal, profile = vaccination_animal + shell = MedicalRecord.objects.create( + animal=animal, author=profile, type_of_event="vaccination_note", short_description="Flu" + ) + VaccinationNote.objects.create( + related_note=shell, + vaccine_name="Flu", + reminder_date=date(2026, 1, 1), + reminder_sent=False, + ) + + due = list(due_vaccination_reminders(date(2026, 6, 1))) + assert len(due) == 1 + assert due[0].vaccine_name == "Flu" + + def test_due_vaccination_reminders_excludes_already_sent(self, vaccination_animal): + from datetime import date + + from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord + from ahc.apps.medical_notes.models.type_vaccination_notes import VaccinationNote + from ahc.apps.medical_notes.selectors import due_vaccination_reminders + + animal, profile = vaccination_animal + shell = MedicalRecord.objects.create( + animal=animal, author=profile, type_of_event="vaccination_note", short_description="Parvovirus" + ) + VaccinationNote.objects.create( + related_note=shell, + vaccine_name="Parvovirus", + reminder_date=date(2026, 1, 1), + reminder_sent=True, + ) + + due = list(due_vaccination_reminders(date(2026, 6, 1))) + assert due == [] diff --git a/src/ahc/apps/medical_notes/urls.py b/src/ahc/apps/medical_notes/urls.py index cf54c43..25b8744 100644 --- a/src/ahc/apps/medical_notes/urls.py +++ b/src/ahc/apps/medical_notes/urls.py @@ -3,6 +3,7 @@ from ahc.apps.medical_notes.views import type_basic_note as notes_views from ahc.apps.medical_notes.views import type_feeding_notes as feeding_views from ahc.apps.medical_notes.views import type_measurement_notes as measurement_views +from ahc.apps.medical_notes.views import type_vaccination_notes as vaccination_views urlpatterns = [ path("<uuid:pk>/create/", notes_views.CreateNoteFormView.as_view(), name="note_create"), @@ -29,4 +30,17 @@ notes_views.DownloadAttachmentView.as_view(), name="attachment_download", ), + path("<uuid:pk>/vaccination/add/", vaccination_views.VaccinationAddView.as_view(), name="vaccination_add"), + path("<uuid:vacc_id>/vaccination/edit/", vaccination_views.VaccinationEditView.as_view(), name="vaccination_edit"), + path("<uuid:vacc_id>/vaccination/save/", vaccination_views.VaccinationSaveView.as_view(), name="vaccination_save"), + path( + "<uuid:vacc_id>/vaccination/cancel/", + vaccination_views.VaccinationCancelView.as_view(), + name="vaccination_cancel", + ), + path( + "<uuid:vacc_id>/vaccination/delete/", + vaccination_views.VaccinationDeleteView.as_view(), + name="vaccination_delete", + ), ] diff --git a/src/ahc/apps/medical_notes/views/type_vaccination_notes.py b/src/ahc/apps/medical_notes/views/type_vaccination_notes.py new file mode 100644 index 0000000..33fc90d --- /dev/null +++ b/src/ahc/apps/medical_notes/views/type_vaccination_notes.py @@ -0,0 +1,143 @@ +"""Views for inline CRUD of VaccinationNote records. + +All views return HTML fragments (partial <tr> elements) consumed by htmx +on the Vaccinations tab. Each view redirects to the Vaccinations tab when +accessed without the HX-Request header (progressive-enhancement fallback). +""" + +from __future__ import annotations + +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.http import HttpResponse +from django.shortcuts import get_object_or_404 +from django.urls import reverse +from django.views import View + +from ahc.apps.animals.models import Animal +from ahc.apps.animals.selectors import allowed_categories_for, is_animal_owner, user_can_access_animal +from ahc.apps.medical_notes.forms.type_vaccination_notes import VaccinationNoteForm +from ahc.apps.medical_notes.models.type_vaccination_notes import VaccinationNote +from ahc.apps.medical_notes.services.vaccinations import ( + create_vaccination_note, + delete_vaccination_note, + update_vaccination_note, +) + + +def _has_vaccination_access(profile, animal: Animal) -> bool: + """Return True if profile may read/write vaccinations on this animal.""" + if not user_can_access_animal(profile, animal): + return False + if is_animal_owner(profile, animal): + return True + allowed = allowed_categories_for(profile, animal) + return "vaccinations" in allowed + + +def _vaccinations_tab_url(animal_id) -> str: + return reverse("animal_tab", kwargs={"pk": animal_id, "slug": "vaccinations"}) + + +class VaccinationAnimalAccessMixin(LoginRequiredMixin, UserPassesTestMixin): + """Access check for views where pk in URL is an Animal UUID.""" + + def test_func(self) -> bool: + animal = get_object_or_404(Animal, id=self.kwargs["pk"]) + return _has_vaccination_access(self.request.user.profile, animal) + + +class VaccinationRecordAccessMixin(LoginRequiredMixin, UserPassesTestMixin): + """Access check for views where vacc_id in URL is a VaccinationNote UUID.""" + + def _get_vaccination(self) -> VaccinationNote: + return get_object_or_404(VaccinationNote, id=self.kwargs["vacc_id"]) + + def test_func(self) -> bool: + vaccination = self._get_vaccination() + animal = vaccination.related_note.animal + return _has_vaccination_access(self.request.user.profile, animal) + + +class VaccinationAddView(VaccinationAnimalAccessMixin, View): + """GET: render empty editable row. POST: create record, return read-only row.""" + + def get(self, request, pk): + form = VaccinationNoteForm() + return HttpResponse(_render_form_row(request, form, animal_id=pk, is_new=True)) + + def post(self, request, pk): + animal = get_object_or_404(Animal, id=pk) + form = VaccinationNoteForm(request.POST) + if form.is_valid(): + vaccination = create_vaccination_note(request.user.profile, animal, form) + return HttpResponse(_render_readonly_row(request, vaccination)) + return HttpResponse( + _render_form_row(request, form, animal_id=pk, is_new=True), + status=422, + ) + + +class VaccinationEditView(VaccinationRecordAccessMixin, View): + """GET: return editable row populated with current values.""" + + def get(self, request, vacc_id): + vaccination = self._get_vaccination() + form = VaccinationNoteForm(instance=vaccination) + return HttpResponse(_render_form_row(request, form, vaccination=vaccination, is_new=False)) + + +class VaccinationSaveView(VaccinationRecordAccessMixin, View): + """POST: save changes to an existing VaccinationNote, return read-only row.""" + + def post(self, request, vacc_id): + vaccination = self._get_vaccination() + form = VaccinationNoteForm(request.POST, instance=vaccination) + if form.is_valid(): + vaccination = update_vaccination_note(vaccination, form) + return HttpResponse(_render_readonly_row(request, vaccination)) + return HttpResponse( + _render_form_row(request, form, vaccination=vaccination, is_new=False), + status=422, + ) + + +class VaccinationCancelView(VaccinationRecordAccessMixin, View): + """GET: discard edits, return read-only row (no DB change).""" + + def get(self, request, vacc_id): + vaccination = self._get_vaccination() + return HttpResponse(_render_readonly_row(request, vaccination)) + + +class VaccinationDeleteView(VaccinationRecordAccessMixin, View): + """POST: delete record, return empty response so htmx removes the row.""" + + def post(self, request, vacc_id): + vaccination = self._get_vaccination() + delete_vaccination_note(vaccination) + return HttpResponse("") + + +def _render_readonly_row(request, vaccination: VaccinationNote) -> str: + from django.template.loader import render_to_string + + return render_to_string( + "medical_notes/partials/_vaccination_row.html", + {"vaccination": vaccination}, + request=request, + ) + + +def _render_form_row(request, form, animal_id=None, vaccination: VaccinationNote | None = None, is_new: bool = True) -> str: + from django.template.loader import render_to_string + + return render_to_string( + "medical_notes/partials/_vaccination_row_form.html", + { + "form": form, + "animal_id": animal_id, + "vaccination": vaccination, + "is_new": is_new, + }, + request=request, + ) diff --git a/static/js/tabs.js b/static/js/tabs.js index e6b937d..6ea800b 100644 --- a/static/js/tabs.js +++ b/static/js/tabs.js @@ -44,5 +44,8 @@ if (typeof initTimelineJump === "function") { initTimelineJump(); } + if (typeof initVaccinationTable === "function") { + initVaccinationTable(); + } }); }()); diff --git a/static/js/vaccination_table.js b/static/js/vaccination_table.js new file mode 100644 index 0000000..a2968b2 --- /dev/null +++ b/static/js/vaccination_table.js @@ -0,0 +1,64 @@ +// Sortable vaccination table. Called by tabs.js after every htmx swap. +// Uses a data-sortInit flag on the <table> element for idempotency: +// - fresh table load (new DOM element) → no flag → init runs and attaches listeners +// - row-level swap (same <table> element) → flag present → init returns early + +function initVaccinationTable() { + var table = document.getElementById("vaccination-table"); + if (!table || table.dataset.sortInit) return; + table.dataset.sortInit = "1"; + + table.querySelectorAll("thead th[data-sort]").forEach(function (th) { + th.style.cursor = "pointer"; + th.style.userSelect = "none"; + th.addEventListener("click", function () { + _sortVaccinationBy(table, th); + }); + }); + + var defaultTh = table.querySelector("thead th[data-default-sort]"); + if (defaultTh) { + _sortVaccinationBy(table, defaultTh, defaultTh.dataset.defaultSort); + } +} + +function _sortVaccinationBy(table, th, forceDir) { + var isAsc = forceDir ? forceDir === "asc" : th.dataset.dir !== "asc"; + + table.querySelectorAll("thead th[data-sort]").forEach(function (h) { + h.dataset.dir = ""; + h.textContent = h.dataset.label; + }); + th.dataset.dir = isAsc ? "asc" : "desc"; + th.textContent = th.dataset.label + (isAsc ? " ▲" : " ▼"); + + var tbody = table.querySelector("tbody"); + var colIdx = Array.from(table.querySelectorAll("thead th")).indexOf(th); + var sortType = th.dataset.sort; + + var rows = Array.from(tbody.querySelectorAll("tr")).filter(function (row) { + return !row.querySelector("input, button"); + }); + + rows.sort(function (a, b) { + var aCell = a.querySelectorAll("td")[colIdx]; + var bCell = b.querySelectorAll("td")[colIdx]; + if (!aCell || !bCell) return 0; + + var aVal = aCell.textContent.trim(); + var bVal = bCell.textContent.trim(); + + if (sortType === "date") { + var aTime = (aVal === "—" || aVal === "") ? null : new Date(aVal).getTime(); + var bTime = (bVal === "—" || bVal === "") ? null : new Date(bVal).getTime(); + if (aTime === null && bTime === null) return 0; + if (aTime === null) return 1; + if (bTime === null) return -1; + return isAsc ? aTime - bTime : bTime - aTime; + } + + return isAsc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); + }); + + rows.forEach(function (row) { tbody.appendChild(row); }); +} From 8522c1983f50f89d6987171281e37f3a0d973230 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:22:18 +0200 Subject: [PATCH 14/15] feat(notifications): vaccination reminders via daily Celery Beat task --- .../0004_profile_discord_user_id.py | 22 ++++++++++ src/ahc/apps/users/models.py | 7 +++ src/celery_notifications/config.py | 11 +++++ src/celery_notifications/cron.py | 44 +++++++++++++++++++ 4 files changed, 84 insertions(+) create mode 100644 src/ahc/apps/users/migrations/0004_profile_discord_user_id.py diff --git a/src/ahc/apps/users/migrations/0004_profile_discord_user_id.py b/src/ahc/apps/users/migrations/0004_profile_discord_user_id.py new file mode 100644 index 0000000..8dca64b --- /dev/null +++ b/src/ahc/apps/users/migrations/0004_profile_discord_user_id.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.5 on 2026-06-02 20:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0003_profile_pinned_animals"), + ] + + operations = [ + migrations.AddField( + model_name="profile", + name="discord_user_id", + field=models.CharField( + blank=True, + default="", + help_text="Discord user snowflake ID for notification delivery. Leave blank to disable Discord reminders.", + max_length=32, + ), + ), + ] diff --git a/src/ahc/apps/users/models.py b/src/ahc/apps/users/models.py index badcf02..127c031 100644 --- a/src/ahc/apps/users/models.py +++ b/src/ahc/apps/users/models.py @@ -14,6 +14,13 @@ class Profile(models.Model): allow_recennt_animals_list = models.BooleanField(default=True) + discord_user_id = models.CharField( + max_length=32, + blank=True, + default="", + help_text="Discord user snowflake ID for notification delivery. Leave blank to disable Discord reminders.", + ) + pinned_animals = models.ManyToManyField("animals.Animal", related_name="+") def __str__(self): diff --git a/src/celery_notifications/config.py b/src/celery_notifications/config.py index 6f4a3b1..6c87bac 100644 --- a/src/celery_notifications/config.py +++ b/src/celery_notifications/config.py @@ -26,11 +26,22 @@ def dispatch_discord_notes(): send_discord_notes() +@celery_obj.task(name="ahc.beat.dispatch_vaccination_reminders") +def dispatch_vaccination_reminders(): + from celery_notifications.cron import send_vaccination_reminders + + send_vaccination_reminders() + + celery_obj.conf.beat_schedule = { "send-discord-notes-hourly": { "task": "ahc.beat.dispatch_discord_notes", "schedule": crontab(minute=6), }, + "send-vaccination-reminders-daily": { + "task": "ahc.beat.dispatch_vaccination_reminders", + "schedule": crontab(hour=8, minute=0), + }, } diff --git a/src/celery_notifications/cron.py b/src/celery_notifications/cron.py index de1a3c2..24597e7 100644 --- a/src/celery_notifications/cron.py +++ b/src/celery_notifications/cron.py @@ -147,6 +147,50 @@ def send_discord_notes(): send_discord_notifications.apply_async(kwargs={"user_id": user_id, "user_message": user_message}, countdown=delay) +@log_exceptions_and_notifications +def send_vaccination_reminders() -> None: + """Send Discord reminders for vaccinations whose reminder_date is today or overdue. + + Marks each record as reminder_sent=True after queuing so that the same + reminder is not dispatched again on the next daily run. + + Animals whose owners have no discord_user_id are silently skipped + (a warning is logged). Actual Discord delivery remains stubbed until + DISCORD_TOKEN is configured in the environment. + """ + from datetime import date + + from ahc.apps.medical_notes.selectors import due_vaccination_reminders + + today = date.today() + due = due_vaccination_reminders(today) + + for vaccination in due: + animal = vaccination.related_note.animal + owner = animal.owner + + if not owner or not owner.discord_user_id: + logger.warning( + "Vaccination reminder skipped — owner has no discord_user_id (animal=%s, vaccine=%s)", + animal.id, + vaccination.vaccine_name, + ) + continue + + valid_str = vaccination.valid_until.strftime("%Y-%m-%d") if vaccination.valid_until else "unknown" + user_message = ( + f"Vaccination reminder: {animal.full_name} is due for '{vaccination.vaccine_name}' (valid until {valid_str})." + ) + + send_discord_notifications.apply_async( + kwargs={"user_id": int(owner.discord_user_id), "user_message": user_message}, + countdown=0, + ) + + vaccination.reminder_sent = True + vaccination.save(update_fields=["reminder_sent"]) + + @task def log_notification_count() -> int: """Django Background Tasks example. Use for simple in-process tasks. From 32b0007cf9a26a752dae8a34b535e615a7ffddd3 Mon Sep 17 00:00:00 2001 From: Cybernetic-Ransomware <71835339+Cybernetic-Ransomware@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:37:45 +0200 Subject: [PATCH 15/15] docs(readme): update --- README.md | 290 ++++++++++++++++++++++++++---------------------------- 1 file changed, 138 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index f060c42..e97b808 100644 --- a/README.md +++ b/README.md @@ -1,164 +1,150 @@ # Animals Healthcare Application -## <strong> A healthcare data management application for pet owners and carers. </strong> - - -#### The application provides an extensive notebook that offers: -- A clear timeline filtered by tags and note types. -- Manage your pet's profile, ownership and authorization. -- Share notes between users and animals. -- Registration of biometric measurement data. -- Managing a diet plan with the option of setting reminder notifications via e-mail or Discord. -- Archiving notes from visits to medical facilities. - ---- -### Functionality: -[ADR](doc/01_adr_functionality.md) - ---- -### Screenshots ->Click on the image to view full-size - -<div align="center"> - <table> - <tr> - <td align="center"><p>Animal profile</p><img src="static/media/readme_examples/Animal profile.png" height="250px"></td> - <td align="center"><p>Full timeline of notes</p><img src="static/media/readme_examples/Full timeline of notes.png" height="250px"></td> - </tr> - <tr> - <td align="center"><p>Diet note details</p><img src="static/media/readme_examples/Diet note details.png" height="250px"></td> - <td align="center"><p>User registration</p><img src="static/media/readme_examples/User registration.png" height="250px"></td> - </tr> - </table> -</div> - - ---- -### Plans for further development: - -- Interactive charts for biometric records -- A book of medical facilities and medical personnel -- Databases for medicines and food products -- An SMS gateway, and Messenger chatbots for notifications -- A fixed light-themed frontend, currently blocked in the base.html <html> tag - ---- -### Requirements: +![Python](https://img.shields.io/badge/python-3.14-3776AB?style=for-the-badge&logo=python&logoColor=white) +![Django](https://img.shields.io/badge/Django-6.0-092E20?style=for-the-badge&logo=django&logoColor=white) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-18-336791?style=for-the-badge&logo=postgresql&logoColor=white) +![CouchDB](https://img.shields.io/badge/CouchDB-3.3-E42528?style=for-the-badge&logo=apachecouchdb&logoColor=white) +![Redis](https://img.shields.io/badge/Redis-7-DC382D?style=for-the-badge&logo=redis&logoColor=white) +![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white) +![Ruff](https://img.shields.io/badge/Ruff-FCC21B?style=for-the-badge&logo=ruff&logoColor=black) +![Pytest](https://img.shields.io/badge/pytest-0A9EDC?style=for-the-badge&logo=pytest&logoColor=white) +![UV](https://img.shields.io/badge/UV-DE5FE9?style=for-the-badge&logo=python&logoColor=white) + +A Django monolith for managing pet health data — medical timelines, diet logs, biometric records, and scheduled notifications. + +## Overview + +Pet owners and carers register animals, maintain detailed health records, and share selective access with other users. The system provides a unified timeline of medical events filtered by type and tag, diet and medication tracking, and automated reminders delivered via Discord. + +## Features + +- Animal profiles with configurable per-category sharing between owners and carers. +- Medical timeline filtered by note type (visit, diet, medication, vaccination, biometric) and tag. +- Inline-editable vaccination records with date-based Discord reminders. +- Biometric tracking (weight, height, custom measurements) with historical charts planned. +- Diet plan management with recurring e-mail / Discord notification schedules. +- Attachment storage for medical documents via CouchDB. +- Async task processing (Celery Beat + Redis) for scheduled notifications. + +## Requirements + - Python 3.14 -- [uv](https://docs.astral.sh/uv/) (package manager) -- [just](https://just.systems/) (task runner, optional) -- Docker & Docker Compose -- PostgreSQL 15 (instance for volumes) -- Apache CouchDB 3.3.3 (instance for volumes) -- [Packages](pyproject.toml) -- [pico-1.5.10](https://github.com/picocss/pico/archive/refs/tags/v1.5.10.zip) - ---- -### Deploy steps: -1. Download repository. -2. Set .env file based on template. -3. Install Docker Desktop. -4. Run containers: - ``` - docker-compose up -d --build - ``` - ---- -### Dev-instance steps: -1. Download repository. -2. Set .env file based on the template. -3. Install Python 3.14, Docker Desktop, PostgreSQL and CouchDB as in _Requirements_. -4. Install uv and sync dependencies: - ``` - pip install uv - uv sync - ``` -5. Install pre-commit hooks: - ``` - uv run pre-commit install - ``` -6. Run containers: - ``` - docker-compose up -d --build - ``` - -With `just` installed, steps 4–6 simplify to: -``` +- [uv](https://docs.astral.sh/uv/) — package manager +- [just](https://just.systems/) — task runner (optional) +- Docker Desktop / Docker + Compose +- PostgreSQL 18 (managed via Docker) +- Apache CouchDB 3.3.3 (managed via Docker) +- Redis 7 (managed via Docker) + +## Environment Variables + +Copy `.env.template` to `.env` and fill in the values: + +| Variable | Required | Description | +|---|---|---| +| `SECRET_KEY` | yes | Django secret key | +| `DATABASE_URL` | yes | PostgreSQL connection string | +| `COUCH_DB_URL` | yes | CouchDB connection URL | +| `COUCH_DB_NAME` | yes | CouchDB database name | +| `CELERY_BROKER_URL` | yes | Redis broker URL | +| `CELERY_BACKEND` | yes | Celery result backend URL | +| `DISCORD_TOKEN` | no | Bot token for Discord notifications | +| `EMAIL_HOST` | no | SMTP host for e-mail notifications | +| `EMAIL_HOST_USER` | no | SMTP user | +| `EMAIL_HOST_PASSWORD` | no | SMTP password | + +## Getting Started + +### Docker Deploy + +1. Clone the repository. +2. Set up the `.env` file based on the provided template. +3. Start all services: + ```powershell + docker compose -f docker/docker-compose.yml up -d --build + ``` + +The stack exposes: Django app on `:8000`, Flower (Celery monitor) on `:5555`. + +### Dev Instance + +1. Clone the repository. +2. Set up the `.env` file based on the provided template. +3. Install dependencies: + ```powershell + pip install uv + uv sync + ``` +4. Install pre-commit hooks: + ```powershell + uv run pre-commit install + ``` +5. Start backing services (PostgreSQL, CouchDB, Redis, Celery): + ```powershell + docker compose -f docker/docker-compose.yml up -d postgres_db couch_db redis queue celery_beat + ``` +6. Run the Django dev server: + ```powershell + uv run python manage.py runserver + ``` + +With `just` installed, steps 3–6 simplify to: +```powershell just install just precommit -just docker-up +just up ``` ---- -### Kubernetes Deploy steps (alternative deploy): -1. Download repository. -2. Set secret.yaml files based on templates. - - Configure the secret.yaml files based on the templates provided in the kubernetes directory (5 files). -3. Install Docker Desktop. -4. Build Docker images: - - Build the Docker images for web, CouchDB, PostgreSQL, and Celery services, - - Example commands: - ``` - docker-compose build - docker image save -o ahc-app.tar ahc-app:latest - docker image save -o ahc_app-couch_db.tar ahc_app-couch_db:latest - docker image save -o postgres.tar postgres:18-alpine - ``` - -5. Push Docker images to a registry: - - Push the Docker images to a container registry, - - Example using Minikube: - ``` - minikube image load ahc-app.tar - minikube image load ahc_app-couch_db.tar - minikube image load postgres.tar # postgres:18-alpine - ``` - -6. Deploy to Kubernetes using kustom files: - - Deploy the application to Kubernetes using the kustomization files, - - Example command: - ``` - kubectl apply -k kubernetes/ - ``` - -7. Verify deployment: - - Verify the deployment using a tool like K8s Lens, - - Alternatively, check the status with the following command: - ``` - kubectl get pods,svc,deploy,ing - ``` - - ---- -### Test running: -```bash -# pytest (recommended) -uv run pytest -m integration - -# or with just +### Kubernetes Deploy + +See [`kubernetes/`](kubernetes/) for kustomization files and secret templates. +Build and load images, then apply with `kubectl apply -k kubernetes/`. + +## Testing + +```powershell +# Run all tests just test + +# Unit tests only +uv run pytest -m unit + +# Integration tests (requires Docker services running) just test-integration +``` -# Django runner (legacy, still supported) -uv run python manage.py test +## Linting + +```powershell +# Full suite: ruff format + check, ty, codespell, bandit +just lint ``` ---- -### Sources: - -* Styles: - * https://picocss.com/ - * https://uicookies.com/horizontal-timeline/ -* Graphics: - * https://www.flaticon.com/authors/futuer - * https://www.flaticon.com/authors/pixel-perfect - * https://www.flaticon.com/authors/riajulislam - * https://www.midjourney.com/ - * https://pixabay.com/ -* Knowledge: - * https://www.devs-mentoring.pl/ - - -To all the people upper mentioned and not only there, -thank You for your work and positive influence on my motivation! -Keep still doing your best! +## Screenshots + +> Click on an image to view full-size. + +| Animal profile | Full timeline of notes | +|:---:|:---:| +| ![Animal profile](static/media/readme_examples/Animal%20profile.png) | ![Full timeline of notes](static/media/readme_examples/Full%20timeline%20of%20notes.png) | +| **Diet note details** | **User registration** | +| ![Diet note details](static/media/readme_examples/Diet%20note%20details.png) | ![User registration](static/media/readme_examples/User%20registration.png) | + +## Architecture Decisions + +Key decisions are documented as ADRs in [`doc/`](doc/): + +| ADR | Topic | +|---|---| +| [01](doc/01_adr_functionality.md) | Core functionality scope | +| [08](doc/08_adr_databases.md) | PostgreSQL + CouchDB + Redis | +| [09](doc/09_adr_user_data.md) | Data model — Animal fields and sharing | +| [11](doc/11_adr_frontend_interactions.md) | htmx + native `<dialog>` | + +## Useful Links + +- [PicoCSS](https://picocss.com/) — CSS framework +- [htmx](https://htmx.org/) — frontend interactions +- [Celery](https://docs.celeryq.dev/) — async task queue +- [uv](https://docs.astral.sh/uv/) — package manager +- [devs-mentoring.pl](https://www.devs-mentoring.pl/) — mentoring programme