diff --git a/.gitattributes b/.gitattributes index e4109b1..cb58941 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,14 +1,18 @@ -# 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 +*.sh 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/README.md b/README.md index 8bc0102..e97b808 100644 --- a/README.md +++ b/README.md @@ -1,166 +1,150 @@ # Animals Healthcare Application -## A healthcare data management application for pet owners and carers. - - -#### 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 - -
- - - - - - - - - -

Animal profile

Full timeline of notes

Diet note details

User registration

-
- - ---- -### 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 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-web.tar ahc_app-web:latest - docker image save -o ahc_app-queue.tar ahc_app-queue: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-web.tar - minikube image load ahc_app-queue.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 `` | + +## 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 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/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 (`` + 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 `` 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 257e8df..3b6b5a5 100644 --- a/doc/09_adr_user_data.md +++ b/doc/09_adr_user_data.md @@ -1,86 +1,125 @@ -## 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-06-01`) ### 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; `through="AnimalShare"`; `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. + +#### `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 "" 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`. + +#### `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`. +`FeedingNote` additionally carries `purchase_source` (CharField 250, optional) — where to buy the product. ### 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. +- 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. - +- data, database, models, Animal, AnimalShare, ShareDefaults, ShareCategory, UserProfile, MedicalNote, sharing, privacy ### Links +- `CLAUDE.md` — Animals App Conventions (field editing pipeline, model idioms) +- ADR-08 — database technology choices (PostgreSQL / CouchDB / Redis) 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) 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/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 65% rename from docker/Dockerfile-web.dockerignore rename to docker/Dockerfile-app.dockerignore index b4c0bc8..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/ @@ -42,3 +43,11 @@ kubernetes/ *.md doc/ + +justfile +.pre-commit-config.yaml +.gitattributes +.vscode/ +tests/ +temp/ +log/ 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 174d707..0000000 --- a/docker/Dockerfile-web +++ /dev/null @@ -1,22 +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 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 7e345ac..0da6c82 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 @@ -43,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 @@ -51,6 +52,28 @@ 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 + +# 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/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/pyproject.toml b/pyproject.toml index 248bda0..2dfd71e 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", @@ -32,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] @@ -57,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" @@ -74,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/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/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 962f461..f6f6552 100644 --- a/src/ahc/apps/animals/models.py +++ b/src/ahc/apps/animals/models.py @@ -1,10 +1,28 @@ +from __future__ import annotations + import uuid +from datetime import date from django.db import models from ahc.apps.users.models import Profile as UserProfile +class Sex(models.TextChoices): + MALE = "m", "Male" + 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) @@ -18,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) @@ -26,3 +44,64 @@ 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) + + +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 3f7f188..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: @@ -66,3 +102,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 shares by Profile PK.""" + AnimalShare.objects.filter(animal=animal, carer_id=keeper_id).delete() 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:

+ {% endif %} {% endblock %} diff --git a/src/ahc/apps/animals/templates/animals/change_animal_details.html b/src/ahc/apps/animals/templates/animals/change_animal_details.html new file mode 100644 index 0000000..4b9e4b1 --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/change_animal_details.html @@ -0,0 +1,15 @@ +{% extends "homepage/base.html" %} +{% load static %} + +{% block content %} +
+

Edit animal details

+
+ {% csrf_token %} + {% include "partials/form_fields.html" %} + +
+
+ Back to Settings +
+{% endblock %} diff --git a/src/ahc/apps/animals/templates/animals/change_birthday.html b/src/ahc/apps/animals/templates/animals/change_birthday.html index 5109645..d093403 100644 --- a/src/ahc/apps/animals/templates/animals/change_birthday.html +++ b/src/ahc/apps/animals/templates/animals/change_birthday.html @@ -1,12 +1,15 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} - {% block content %}
{% 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_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_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_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/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/edit_share.html b/src/ahc/apps/animals/templates/animals/edit_share.html new file mode 100644 index 0000000..5bdeeec --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/edit_share.html @@ -0,0 +1,10 @@ +{% extends "homepage/base.html" %} +{% block content %} +

Edit access for {{ carer_name }}

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

Return to ownership

+{% endblock %} diff --git a/src/ahc/apps/animals/templates/animals/image.html b/src/ahc/apps/animals/templates/animals/image.html index ef53601..cbe7fe6 100644 --- a/src/ahc/apps/animals/templates/animals/image.html +++ b/src/ahc/apps/animals/templates/animals/image.html @@ -1,12 +1,9 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} - {% block content %} -
- {% 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..eee1e4f 100644 --- a/src/ahc/apps/animals/templates/animals/manage_keepers.html +++ b/src/ahc/apps/animals/templates/animals/manage_keepers.html @@ -1,34 +1,27 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} - {% block content %} -
-
- {% csrf_token %} -
- Set a new keeper for {{ full_name }}: - {{ form.input_user|as_crispy_field }} - -
-
-
-
- {% if allowed_users %} +

Add a keeper for {{ full_name }}

+
+ {% csrf_token %} +
+ New keeper + {% include "partials/form_fields.html" %} + +
+
+ + {% if shares %} +

Current keepers

+
    + {% for share in shares %} +
  • + {{ share.carer }} + {% if share.valid_until %} — expires {{ share.valid_until }}{% else %} — no expiry{% endif %} +
  • + {% endfor %} +
+ {% endif %} -
-

Current Keepers:

-
    - {% for user in allowed_users %} -
  • {{ user }}
  • - {% endfor %} -
- {% endif %} -
-
- - Return to Pet profile or Stable - -
-
+
+ Return to Pet profile or Stable {% endblock %} diff --git a/src/ahc/apps/animals/templates/animals/profile.html b/src/ahc/apps/animals/templates/animals/profile.html index b5b146f..6482dd2 100644 --- a/src/ahc/apps/animals/templates/animals/profile.html +++ b/src/ahc/apps/animals/templates/animals/profile.html @@ -1,146 +1,75 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} {% load static %} {% load custom_timesince %} -{% block content %} -
-
-
-
- - - -
- +{% block extra_css %} + + +{% endblock %} - {% if animal.birthdate %} -

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

-

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

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

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: first contact details

-
-

{{ animal.first_contact_vet }}

-

{{ animal.first_contact_medical_place }}

-
-
-
-
-
-

Last records:

-
- - +{% block tab_nav %} + +{% endblock %} -
+{% block content %} +
- +
+ + Animal's profile picture + +
+

{{ animal.full_name }}

-
    - {% for record in recent_records reversed %} -
  1. -
    - - {{ record.short_description }} -
    -
  2. - {% endfor %} -
  3. -
-
-
-
-
-

Manage details:

+ {% if animal.species or animal.breed %} +

{{ animal.species }}{% if animal.species and animal.breed %} / {% endif %}{{ animal.breed }}

+ {% endif %} + {% if animal.sex %} +

Sex: {{ animal.get_sex_display }}

+ {% endif %} +

Sterilized:

-
-

Common options:

- + {% if animal.birthdate %} +

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

+

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

+ {% endif %} + +

Owner: {{ animal.owner }}

+ {% if animal.short_description %} +

{{ animal.short_description }}

+ {% endif %}
- {% if is_owner %} - - {% endif %} +
+
+ {% include active_partial %}
+
{% endblock %} diff --git a/src/ahc/apps/animals/templates/animals/tabs/_diet.html b/src/ahc/apps/animals/templates/animals/tabs/_diet.html new file mode 100644 index 0000000..6cb8f23 --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_diet.html @@ -0,0 +1,53 @@ +{% load static %} +
+ +

Diet

+ + {% if "diet" in allowed_categories %} + + {% if animal.dietary_restrictions %} +
+

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

You do not have access to this section.

+ {% endif %} + +
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_mainpage.html b/src/ahc/apps/animals/templates/animals/tabs/_mainpage.html new file mode 100644 index 0000000..40d34d4 --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_mainpage.html @@ -0,0 +1,32 @@ +{% load static %} +
+ + {% if "basic" in allowed_categories %} +
+
+

Expand: additional description

+
+ {{ animal.long_description|default:"No additional description." }} +
+
+
+ {% endif %} + +
+ + +
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_medications.html b/src/ahc/apps/animals/templates/animals/tabs/_medications.html new file mode 100644 index 0000000..5fb1577 --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_medications.html @@ -0,0 +1,48 @@ +{% load static %} +
+ +

Medications

+ + {% if "medications" in allowed_categories %} + + + +
+ {% if medication_records %} +
+
    + {% for record in medication_records %} +
  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 %} + + {% else %} +

You do not have access to this section.

+ {% endif %} + +
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_notes.html b/src/ahc/apps/animals/templates/animals/tabs/_notes.html new file mode 100644 index 0000000..38de6ce --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_notes.html @@ -0,0 +1,101 @@ +{% load static %} +
+ + {% if "history" in allowed_categories %} +

Notes

+
+
+
+ Add a note + View full timeline + {% if available_months %} + + {% endif %} +
+
+ + {% if other_records %} +
    + {% for record in other_records %} + {% ifchanged record.date_creation|date:"Y-m" %} +
  1. + {% endifchanged %} +
  2. +
    + + {{ record.short_description }} + ({{ record.type_of_event }}) +
    + Edit + Delete +
    +
  3. + {% endfor %} + {% if tl_has_more %} +
  4. + +
  5. + {% endif %} +
  6. +
+ {% else %} +

No notes recorded yet.

+ {% endif %} +
+ {% endif %} + + {% if "biometrics" in allowed_categories %} +

Biometrics

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

No biometric records yet.

+ {% endif %} + {% endif %} + + {% if not "history" in allowed_categories and not "biometrics" in allowed_categories %} +

You do not have access to this section.

+ {% endif %} + +
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_ownership.html b/src/ahc/apps/animals/templates/animals/tabs/_ownership.html new file mode 100644 index 0000000..42d57e4 --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_ownership.html @@ -0,0 +1,49 @@ +{% load static %} +
+ +

Ownership

+

Current owner: {{ animal.owner }}

+ Change owner + +

+

Keepers

+ {% if keepers %} +
    + {% for share in keepers %} +
  • +
    + {{ share.carer }} + + {% if share.valid_until %} + Expires: {{ share.valid_until }} + {% else %} + No expiry + {% endif %} + +
      + {% if share.allow_basic %}
    • Basic info
    • {% endif %} + {% if share.allow_vet_contact %}
    • Vet contact
    • {% endif %} + {% if share.allow_diet %}
    • Diet
    • {% endif %} + {% if share.allow_medications %}
    • Medications
    • {% endif %} + {% if share.allow_history %}
    • History & notes
    • {% endif %} + {% if share.allow_biometrics %}
    • Biometrics
    • {% endif %} +
    +
    +
    + Edit access +
    + {% 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/_vaccinations.html b/src/ahc/apps/animals/templates/animals/tabs/_vaccinations.html new file mode 100644 index 0000000..2f1c348 --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_vaccinations.html @@ -0,0 +1,40 @@ +{% load static %} +
+ +

Vaccinations

+ + {% if "vaccinations" in allowed_categories %} + +
+ + + + + + + + + + + + + {% for vaccination in vaccination_records %} + {% include "medical_notes/partials/_vaccination_row.html" %} + {% endfor %} + +
Vaccine nameLast vaccinatedValid untilSuggested clinicReminder dateActions
+
+ + + + {% else %} +

You do not have access to this section.

+ {% endif %} + +
diff --git a/src/ahc/apps/animals/templates/animals/tabs/_vet.html b/src/ahc/apps/animals/templates/animals/tabs/_vet.html new file mode 100644 index 0000000..f97fec6 --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/_vet.html @@ -0,0 +1,96 @@ +{% load static %} +
+ + {% if "vet_contact" in allowed_categories %} +

Veterinary contact

+
+

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 %} +
+ {% endif %} + + {% if "history" in allowed_categories %} +

Medical visit timeline

+
+
+
+ Add vet visit + View all visits + {% if available_months %} + + {% endif %} +
+
+ + {% if vet_records %} +
    + {% for record in vet_records %} + {% ifchanged record.date_creation|date:"Y-m" %} +
  1. + {% endifchanged %} +
  2. +
    + + {{ record.short_description }} +
    +
  3. + {% endfor %} + {% if tl_has_more %} +
  4. + +
  5. + {% endif %} +
  6. +
+ {% else %} +

No medical visits recorded yet.

+ {% endif %} +
+ {% endif %} + + {% if not "vet_contact" in allowed_categories and not "history" in allowed_categories %} +

You do not have access to this section.

+ {% endif %} + +
diff --git a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html new file mode 100644 index 0000000..5371d83 --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_notes.html @@ -0,0 +1,31 @@ +{% for record in other_records %} +{% ifchanged record.date_creation|date:"Y-m" %} +
  • +{% endifchanged %} +
  • +
    + + {{ record.short_description }} + ({{ record.type_of_event }}) +
    + Edit + Delete +
    +
  • +{% endfor %} +{% if tl_has_more %} +
  • + +
  • +{% endif %} diff --git a/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html new file mode 100644 index 0000000..b32a946 --- /dev/null +++ b/src/ahc/apps/animals/templates/animals/tabs/partials/_timeline_nodes_vet.html @@ -0,0 +1,22 @@ +{% for record in vet_records %} +{% ifchanged record.date_creation|date:"Y-m" %} +
  • +{% endifchanged %} +
  • +
    + + {{ record.short_description }} +
    +
  • +{% endfor %} +{% if tl_has_more %} +
  • + +
  • +{% endif %} diff --git a/src/ahc/apps/animals/tests.py b/src/ahc/apps/animals/tests.py index 93d5b15..339df85 100644 --- a/src/ahc/apps/animals/tests.py +++ b/src/ahc/apps/animals/tests.py @@ -16,6 +16,7 @@ create_animal, pin_animal, process_profile_image, + remove_keeper, set_birthday, set_first_contact, transfer_ownership, @@ -97,29 +98,29 @@ def test_returns_false_for_non_owner(self): @pytest.mark.unit class TestUserCanAccessAnimalSelector: - """user_can_access_animal: short-circuits on owner; queries allowed_users otherwise.""" + """user_can_access_animal: short-circuits on owner; delegates to active_share_for otherwise.""" def test_owner_can_access(self): profile = MagicMock() animal = MagicMock() animal.owner = profile - assert user_can_access_animal(profile, animal) is True - animal.allowed_users.filter.assert_not_called() + with patch("ahc.apps.animals.selectors.active_share_for") as mock_share: + assert user_can_access_animal(profile, animal) is True + mock_share.assert_not_called() def test_keeper_can_access(self): profile = MagicMock() animal = MagicMock() animal.owner = MagicMock() - animal.allowed_users.filter.return_value.exists.return_value = True - assert user_can_access_animal(profile, animal) is True - animal.allowed_users.filter.assert_called_once_with(pk=profile.pk) + with patch("ahc.apps.animals.selectors.active_share_for", return_value=MagicMock()): + assert user_can_access_animal(profile, animal) is True def test_stranger_cannot_access(self): profile = MagicMock() animal = MagicMock() animal.owner = MagicMock() - animal.allowed_users.filter.return_value.exists.return_value = False - assert user_can_access_animal(profile, animal) is False + with patch("ahc.apps.animals.selectors.active_share_for", return_value=None): + assert user_can_access_animal(profile, animal) is False @pytest.mark.unit @@ -284,19 +285,20 @@ def test_adds_requesting_as_keeper_when_flag_is_set(self): new_owner = MagicMock() requesting = MagicMock() - transfer_ownership(animal, new_owner, set_keeper=True, requesting_profile=requesting) - - animal.allowed_users.add.assert_called_once_with(requesting) + with patch("ahc.apps.animals.services.create_share") as mock_create_share: + transfer_ownership(animal, new_owner, set_keeper=True, requesting_profile=requesting) + mock_create_share.assert_called_once_with(animal, requesting.pk, scope=None, valid_until=None) @pytest.mark.unit class TestAddKeeperService: - """add_keeper: delegates to M2M.add with the provided keeper id.""" + """add_keeper: delegates to create_share with the provided keeper id and default scope.""" def test_adds_keeper_by_id(self): animal = MagicMock() - add_keeper(animal, 42) - animal.allowed_users.add.assert_called_once_with(42) + with patch("ahc.apps.animals.services.create_share") as mock_create_share: + add_keeper(animal, 42) + mock_create_share.assert_called_once_with(animal, 42, scope=None, valid_until=None) @pytest.mark.unit @@ -316,3 +318,184 @@ def test_set_first_contact_assigns_both_fields_and_saves(self): assert animal.first_contact_vet == "Dr Smith" assert animal.first_contact_medical_place == "City Clinic" animal.save.assert_called_once() + + +@pytest.mark.unit +class TestNewAnimalServices: + """remove_keeper / set_next_visit / set_dietary_restrictions: unit coverage.""" + + def test_remove_keeper_delegates_to_animalshare(self): + animal = MagicMock() + with patch("ahc.apps.animals.services.AnimalShare") as mock_model: + remove_keeper(animal, 99) + mock_model.objects.filter.assert_called_once_with(animal=animal, carer_id=99) + mock_model.objects.filter.return_value.delete.assert_called_once() + + def test_set_next_visit_assigns_date_and_saves(self): + from datetime import date as date_type + + from ahc.apps.animals.services import set_next_visit + + animal = MagicMock() + d = date_type(2026, 9, 1) + set_next_visit(animal, d) + assert animal.next_visit_date == d + animal.save.assert_called_once() + + def test_set_dietary_restrictions_assigns_text_and_saves(self): + from ahc.apps.animals.services import set_dietary_restrictions + + animal = MagicMock() + set_dietary_restrictions(animal, "No grapes, no onions") + assert animal.dietary_restrictions == "No grapes, no onions" + animal.save.assert_called_once() + + def test_remove_keeper_does_not_affect_owner(self): + """Removing a keeper must not touch the owner field.""" + animal = MagicMock() + original_owner = MagicMock() + animal.owner = original_owner + with patch("ahc.apps.animals.services.AnimalShare"): + remove_keeper(animal, 42) + assert animal.owner is original_owner + + +@pytest.mark.integration +@pytest.mark.django_db +class TestOtherRecordsForSelector: + """other_records_for: excludes medical_visit and diet_note types.""" + + @pytest.fixture + def animal(self, db, user_profile): + _, profile = user_profile + return Animal.objects.create(full_name="Buddy", owner=profile) + + def test_excludes_medical_visit_and_diet_note(self, animal, user_profile): + from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord + from ahc.apps.medical_notes.selectors import other_records_for + + _, profile = user_profile + MedicalRecord.objects.create( + animal=animal, author=profile, short_description="Visit", type_of_event="medical_visit" + ) + MedicalRecord.objects.create(animal=animal, author=profile, short_description="Diet", type_of_event="diet_note") + note = MedicalRecord.objects.create( + animal=animal, author=profile, short_description="Other", type_of_event="fast_note" + ) + + results = list(other_records_for(animal)) + ids = [r.id for r in results] + assert note.id in ids + assert len(results) == 1 + + def test_returns_empty_when_only_special_types(self, animal, user_profile): + from ahc.apps.medical_notes.models.type_basic_note import MedicalRecord + from ahc.apps.medical_notes.selectors import other_records_for + + _, profile = user_profile + MedicalRecord.objects.create(animal=animal, author=profile, short_description="V", type_of_event="medical_visit") + assert list(other_records_for(animal)) == [] + + +@pytest.mark.integration +@pytest.mark.django_db +class TestAnimalTabView: + """AnimalTabView: htmx vs full-page response, access control.""" + + @pytest.fixture + def animal(self, db, user_profile): + _, profile = user_profile + return Animal.objects.create(full_name="Luna", owner=profile) + + def _client_for(self, user): + from django.test import Client + + c = Client() + c.force_login(user) + return c + + def test_htmx_request_returns_fragment_without_base_title(self, animal, user_profile): + user, _ = user_profile + c = self._client_for(user) + url = f"/pet/{animal.id}/tab/mainpage/" + response = c.get(url, HTTP_HX_REQUEST="true") + assert response.status_code == 200 + content = response.content.decode() + assert "" not in content + + def test_non_htmx_request_returns_full_page_with_base_title(self, animal, user_profile): + user, _ = user_profile + c = self._client_for(user) + url = f"/pet/{animal.id}/tab/mainpage/" + response = c.get(url) + assert response.status_code == 200 + assert "<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..c9fb55b 100644 --- a/src/ahc/apps/animals/urls.py +++ b/src/ahc/apps/animals/urls.py @@ -10,8 +10,18 @@ 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("<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 6b75907..ed48e89 100644 --- a/src/ahc/apps/animals/utils_owner/forms.py +++ b/src/ahc/apps/animals/utils_owner/forms.py @@ -3,7 +3,7 @@ from django import forms from PIL import Image -from ahc.apps.animals.models import Animal +from ahc.apps.animals.models import Animal, AnimalShare from ahc.apps.users.models import Profile @@ -60,10 +60,36 @@ def clean_new_owner(self): class ManageKeepersForm(forms.Form): input_user = forms.CharField(max_length=255, required=True, label="Full keeper profile name") + valid_until = forms.DateField( + required=False, + label="Access expires on (leave empty for indefinite)", + widget=forms.DateInput(attrs={"type": "date"}), + ) + allow_basic = forms.BooleanField(required=False, label="Basic info") + allow_vet_contact = forms.BooleanField(required=False, label="Vet contact") + allow_diet = forms.BooleanField(required=False, label="Diet") + allow_medications = forms.BooleanField(required=False, label="Medications") + allow_history = forms.BooleanField(required=False, label="History & notes") + allow_biometrics = forms.BooleanField(required=False, label="Biometrics") + allow_vaccinations = forms.BooleanField(required=False, label="Vaccinations") def __init__(self, *args, **kwargs): self.instance = kwargs.pop("instance", None) super().__init__(*args, **kwargs) + # Pre-fill category flags from the owner's share defaults. + from ahc.apps.animals.selectors import get_or_create_share_defaults + + defaults = get_or_create_share_defaults(self.instance.owner) + for field in ( + "allow_basic", + "allow_vet_contact", + "allow_diet", + "allow_medications", + "allow_history", + "allow_biometrics", + "allow_vaccinations", + ): + self.fields[field].initial = getattr(defaults, field) def clean_input_user(self): input_user = self.cleaned_data.get("input_user") @@ -71,15 +97,40 @@ def clean_input_user(self): if input_user == self.instance.owner.user.username: raise forms.ValidationError("As the owner you can not set yourself as a keeper.") - if input_user in self.instance.allowed_users.all(): + if self.instance.shares.filter(carer__user__username=input_user).exists(): raise forms.ValidationError("User is already on the list of keepers.") - if not Profile.objects.filter(user__username=input_user).exists(): + profile = Profile.objects.filter(user__username=input_user).first() + if profile is None: raise forms.ValidationError("User does not exist.") - input_user_id = Profile.objects.filter(user__username=input_user).first().id + return profile.pk - return input_user_id + +class EditShareForm(forms.ModelForm): + class Meta: + model = AnimalShare + fields = [ + "valid_until", + "allow_basic", + "allow_vet_contact", + "allow_diet", + "allow_medications", + "allow_history", + "allow_biometrics", + "allow_vaccinations", + ] + widgets = {"valid_until": forms.DateInput(attrs={"type": "date"})} + labels = { + "valid_until": "Access expires on (leave empty for indefinite)", + "allow_basic": "Basic info", + "allow_vet_contact": "Vet contact", + "allow_diet": "Diet", + "allow_medications": "Medications", + "allow_history": "History & notes", + "allow_biometrics": "Biometrics", + "allow_vaccinations": "Vaccinations", + } class ChangeBirthdayForm(forms.ModelForm): @@ -92,7 +143,7 @@ def clean_birthdate(self): birthdate = self.cleaned_data.get("birthdate") current_date = date.today() - if birthdate > current_date: + if birthdate is not None and birthdate > current_date: raise forms.ValidationError("Date could not be set further than current day.") return birthdate @@ -106,3 +157,23 @@ class Meta: "first_contact_vet": forms.Textarea(attrs={"rows": 4, "cols": 2}), "first_contact_medical_place": forms.Textarea(attrs={"rows": 4, "cols": 2}), } + + +class ChangeNextVisitForm(forms.ModelForm): + class Meta: + model = Animal + fields = ["next_visit_date"] + widgets = {"next_visit_date": forms.DateInput(attrs={"type": "date"})} + + +class ChangeDietaryRestrictionsForm(forms.ModelForm): + class Meta: + model = Animal + fields = ["dietary_restrictions"] + widgets = {"dietary_restrictions": forms.Textarea(attrs={"rows": 6, "cols": 2})} + + +class ChangeAnimalDetailsForm(forms.ModelForm): + class Meta: + model = Animal + fields = ["species", "breed", "sex", "sterilization"] diff --git a/src/ahc/apps/animals/utils_owner/views.py b/src/ahc/apps/animals/utils_owner/views.py index 349a652..1e01b20 100644 --- a/src/ahc/apps/animals/utils_owner/views.py +++ b/src/ahc/apps/animals/utils_owner/views.py @@ -1,22 +1,31 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.shortcuts import get_object_or_404, redirect from django.urls import reverse, reverse_lazy -from django.views.generic import DeleteView +from django.views.generic import DeleteView, View from django.views.generic.edit import FormView from ahc.apps.animals.mixins.animal_owner_permissions import UserPassesOwnershipTestMixin -from ahc.apps.animals.models import Animal +from ahc.apps.animals.models import Animal, AnimalShare from ahc.apps.animals.services import ( - add_keeper, + create_share, process_profile_image, + remove_keeper, + set_animal_details, set_birthday, + set_dietary_restrictions, set_first_contact, + set_next_visit, transfer_ownership, + update_share, ) from ahc.apps.animals.utils_owner.forms import ( + ChangeAnimalDetailsForm, ChangeBirthdayForm, + ChangeDietaryRestrictionsForm, ChangeFirstContactForm, + ChangeNextVisitForm, ChangeOwnerForm, + EditShareForm, ImageUploadForm, ManageKeepersForm, ) @@ -92,7 +101,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) animal = Animal.objects.get(pk=self.kwargs["pk"]) context["full_name"] = animal.full_name - context["allowed_users"] = animal.allowed_users.all() + context["shares"] = animal.shares.select_related("carer__user").all() context["animal_url"] = reverse("animal_profile", kwargs={"pk": self.get_form().instance.id}) return context @@ -102,7 +111,16 @@ def get_form_kwargs(self): return kwargs def form_valid(self, form): - add_keeper(form.instance, form.cleaned_data["input_user"]) + cd = form.cleaned_data + scope = { + "allow_basic": cd["allow_basic"], + "allow_vet_contact": cd["allow_vet_contact"], + "allow_diet": cd["allow_diet"], + "allow_medications": cd["allow_medications"], + "allow_history": cd["allow_history"], + "allow_biometrics": cd["allow_biometrics"], + } + create_share(form.instance, cd["input_user"], scope=scope, valid_until=cd.get("valid_until")) return super().form_valid(form) def get_success_url(self): @@ -152,3 +170,114 @@ def form_valid(self, form): def get_success_url(self): return self.request.path + + +class ChangeNextVisitView(LoginRequiredMixin, UserPassesOwnershipTestMixin, FormView): + form_class = ChangeNextVisitForm + template_name = "animals/change_next_visit.html" + + def get_context_data(self, **kwargs): + animal = get_object_or_404(Animal, pk=self.kwargs["pk"]) + context = super().get_context_data(**kwargs) + context["animal_id"] = self.kwargs["pk"] + context["next_visit_date"] = animal.next_visit_date + return context + + def form_valid(self, form): + set_next_visit( + get_object_or_404(Animal, pk=self.kwargs["pk"]), + next_visit_date=form.cleaned_data["next_visit_date"], + ) + success_url = reverse("animal_tab", kwargs={"pk": self.kwargs["pk"], "slug": "vet"}) + return redirect(success_url) + + +class ChangeDietaryRestrictionsView(LoginRequiredMixin, UserPassesOwnershipTestMixin, FormView): + form_class = ChangeDietaryRestrictionsForm + template_name = "animals/change_dietary_restrictions.html" + + def get_context_data(self, **kwargs): + animal = get_object_or_404(Animal, pk=self.kwargs["pk"]) + context = super().get_context_data(**kwargs) + context["animal_id"] = self.kwargs["pk"] + context["current_restrictions"] = animal.dietary_restrictions + return context + + def form_valid(self, form): + set_dietary_restrictions( + get_object_or_404(Animal, pk=self.kwargs["pk"]), + restrictions=form.cleaned_data["dietary_restrictions"], + ) + success_url = reverse("animal_tab", kwargs={"pk": self.kwargs["pk"], "slug": "diet"}) + return redirect(success_url) + + +class ChangeAnimalDetailsView(LoginRequiredMixin, UserPassesOwnershipTestMixin, FormView): + form_class = ChangeAnimalDetailsForm + template_name = "animals/change_animal_details.html" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["instance"] = get_object_or_404(Animal, pk=self.kwargs["pk"]) + return kwargs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["animal_id"] = self.kwargs["pk"] + return context + + def form_valid(self, form): + set_animal_details( + get_object_or_404(Animal, pk=self.kwargs["pk"]), + species=form.cleaned_data["species"], + breed=form.cleaned_data["breed"], + sex=form.cleaned_data["sex"], + sterilization=form.cleaned_data["sterilization"], + ) + success_url = reverse("animal_tab", kwargs={"pk": self.kwargs["pk"], "slug": "settings"}) + return redirect(success_url) + + +class RemoveKeeperView(LoginRequiredMixin, UserPassesOwnershipTestMixin, View): + """Remove a single keeper from the animal's shares (owner-only, POST).""" + + def post(self, request, pk, keeper_pk): + animal = get_object_or_404(Animal, pk=pk) + remove_keeper(animal, keeper_pk) + return redirect(reverse("animal_tab", kwargs={"pk": pk, "slug": "ownership"})) + + +class EditShareView(LoginRequiredMixin, UserPassesOwnershipTestMixin, FormView): + """Edit the access scope and expiry date of an existing AnimalShare (owner-only).""" + + template_name = "animals/edit_share.html" + form_class = EditShareForm + + def _get_share(self) -> AnimalShare: + animal = get_object_or_404(Animal, pk=self.kwargs["pk"]) + return get_object_or_404(AnimalShare, animal=animal, carer_id=self.kwargs["keeper_pk"]) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["instance"] = self._get_share() + return kwargs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["animal_id"] = self.kwargs["pk"] + share = self._get_share() + context["carer_name"] = share.carer.user.username + return context + + def form_valid(self, form): + cd = form.cleaned_data + scope = { + "allow_basic": cd["allow_basic"], + "allow_vet_contact": cd["allow_vet_contact"], + "allow_diet": cd["allow_diet"], + "allow_medications": cd["allow_medications"], + "allow_history": cd["allow_history"], + "allow_biometrics": cd["allow_biometrics"], + } + update_share(form.instance, scope=scope, valid_until=cd.get("valid_until")) + return redirect(reverse("animal_tab", kwargs={"pk": self.kwargs["pk"], "slug": "ownership"})) diff --git a/src/ahc/apps/animals/views.py b/src/ahc/apps/animals/views.py index 5ecc377..85ad176 100644 --- a/src/ahc/apps/animals/views.py +++ b/src/ahc/apps/animals/views.py @@ -1,7 +1,15 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import date, datetime +from typing import Any + from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.http import JsonResponse +from django.http import Http404, JsonResponse from django.urls import reverse from django.utils import timezone +from django.utils.dateparse import parse_datetime from django.views.generic import TemplateView, View from django.views.generic.detail import DetailView from django.views.generic.edit import FormView @@ -9,15 +17,257 @@ from ahc.apps.animals.forms import AnimalRegisterForm, PinAnimalForm from ahc.apps.animals.models import Animal from ahc.apps.animals.selectors import ( + allowed_categories_for, animals_visible_to, is_animal_owner, is_pinned, - recent_records_for, user_can_access_animal, ) from ahc.apps.animals.services import create_animal, pin_animal, unpin_animal +@dataclass +class Tab: + slug: str + label: str + template: str + owner_only: bool + build: Callable[..., dict[str, Any]] + categories: frozenset[str] = frozenset() + + +def _build_mainpage(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]: + return {} + + +_TIMELINE_PER_PAGE = 20 + + +def _timeline_boundary_from_month(month_param: str) -> datetime | None: + """Return the start of the month AFTER month_param as an aware local datetime. + + Used to filter records for a month-jump: records with date_creation < boundary + start exactly at the end of the target month. Returns None on parse failure. + """ + try: + target = date.fromisoformat(month_param + "-01") + except ValueError: + return None + first_of_next = date(target.year + 1, 1, 1) if target.month == 12 else date(target.year, target.month + 1, 1) + tz = timezone.get_current_timezone() + return timezone.make_aware(datetime(first_of_next.year, first_of_next.month, first_of_next.day, 0, 0, 0), tz) + + +def _build_vet(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]: + ctx: dict[str, Any] = {} + if allowed is None or "vet_contact" in allowed: + ctx["show_vet_contact"] = True + + if allowed is None or "history" in allowed: + from ahc.apps.medical_notes.selectors import available_months_for, timeline_for + + qs = timeline_for(animal, type_of_event="medical_visit").order_by("-date_creation") + + month_param = request.GET.get("month") + before_param = request.GET.get("before") + + if month_param and not before_param: + boundary = _timeline_boundary_from_month(month_param) + if boundary: + qs = qs.filter(date_creation__lt=boundary) + elif before_param: + before_dt = parse_datetime(before_param) + if before_dt: + qs = qs.filter(date_creation__lt=before_dt) + + records = list(qs[: _TIMELINE_PER_PAGE + 1]) + tl_has_more = len(records) > _TIMELINE_PER_PAGE + if tl_has_more: + records = records[:_TIMELINE_PER_PAGE] + + ctx.update( + { + "vet_records": records, + "tl_has_more": tl_has_more, + "tl_next_before": records[-1].date_creation.isoformat() if records else None, + "tl_slug": "vet", + "scroll_to_month": month_param or "", + "available_months": available_months_for(animal, type_of_event="medical_visit"), + } + ) + return ctx + + +def _build_diet(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]: + if allowed is not None and "diet" not in allowed: + return {} + from ahc.apps.medical_notes.selectors import timeline_for + + return { + "diet_records": timeline_for(animal, type_of_event="diet_note").order_by("-date_creation"), + } + + +def _build_medications(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]: + if allowed is not None and "medications" not in allowed: + return {} + from ahc.apps.medical_notes.selectors import medication_notes_for + + return {"medication_records": medication_notes_for(animal)} + + +def _build_notes(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]: + ctx: dict[str, Any] = {} + + if allowed is None or "history" in allowed: + from ahc.apps.medical_notes.selectors import other_history_for + + qs = other_history_for(animal) + + month_param = request.GET.get("month") + before_param = request.GET.get("before") + + if month_param and not before_param: + boundary = _timeline_boundary_from_month(month_param) + if boundary: + qs = qs.filter(date_creation__lt=boundary) + elif before_param: + before_dt = parse_datetime(before_param) + if before_dt: + qs = qs.filter(date_creation__lt=before_dt) + + records = list(qs[: _TIMELINE_PER_PAGE + 1]) + tl_has_more = len(records) > _TIMELINE_PER_PAGE + if tl_has_more: + records = records[:_TIMELINE_PER_PAGE] + + available_months = list( + other_history_for(animal).datetimes( + "date_creation", + "month", + order="DESC", + tzinfo=timezone.get_current_timezone(), + ) + ) + + ctx.update( + { + "other_records": records, + "tl_has_more": tl_has_more, + "tl_next_before": records[-1].date_creation.isoformat() if records else None, + "tl_slug": "notes", + "scroll_to_month": month_param or "", + "available_months": available_months, + } + ) + + if allowed is None or "biometrics" in allowed: + from ahc.apps.medical_notes.selectors import biometric_records_for + + ctx["biometric_records"] = biometric_records_for(animal) + + return ctx + + +def _build_ownership(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]: + return {"keepers": animal.shares.select_related("carer__user").all()} + + +def _build_settings(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]: + return {} + + +def _build_vaccinations(request, animal: Animal, allowed: set[str] | None = None) -> dict[str, Any]: + if allowed is not None and "vaccinations" not in allowed: + return {} + from ahc.apps.medical_notes.selectors import vaccination_notes_for + + return {"vaccination_records": vaccination_notes_for(animal)} + + +TAB_REGISTRY: dict[str, Tab] = { + tab.slug: tab + for tab in [ + Tab( + "mainpage", + "Overview", + "animals/tabs/_mainpage.html", + False, + _build_mainpage, + frozenset({"basic"}), + ), + Tab( + "vet", + "Vet & Visits", + "animals/tabs/_vet.html", + False, + _build_vet, + frozenset({"vet_contact", "history"}), + ), + Tab( + "diet", + "Diet", + "animals/tabs/_diet.html", + False, + _build_diet, + frozenset({"diet"}), + ), + Tab( + "medications", + "Medications", + "animals/tabs/_medications.html", + False, + _build_medications, + frozenset({"medications"}), + ), + Tab( + "notes", + "Notes", + "animals/tabs/_notes.html", + False, + _build_notes, + frozenset({"history", "biometrics"}), + ), + Tab( + "vaccinations", + "Vaccinations", + "animals/tabs/_vaccinations.html", + False, + _build_vaccinations, + frozenset({"vaccinations"}), + ), + Tab("ownership", "Ownership", "animals/tabs/_ownership.html", True, _build_ownership), + Tab("settings", "Settings", "animals/tabs/_settings.html", True, _build_settings), + ] +} + +TABS_LIST: list[Tab] = list(TAB_REGISTRY.values()) + +DEFAULT_TAB_SLUG = "mainpage" + + +def _base_profile_context(request, animal: Animal) -> dict[str, Any]: + """Shared context for profile.html shell and AnimalTabView.""" + profile = request.user.profile + owner = is_animal_owner(profile, animal) + allowed = allowed_categories_for(profile, animal) + + def _tab_visible(tab: Tab) -> bool: + if tab.owner_only: + return owner + if not tab.categories: + return True + return owner or bool(tab.categories & allowed) + + return { + "now": timezone.now().date(), + "is_owner": owner, + "is_pinned": is_pinned(profile, animal), + "allowed_categories": allowed, + "tabs": [t for t in TABS_LIST if _tab_visible(t)], + } + + class CreateAnimalView(LoginRequiredMixin, FormView): template_name = "animals/create.html" form_class = AnimalRegisterForm @@ -30,18 +280,23 @@ def form_valid(self, form): class AnimalProfileDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView): + """Entry point for the animal profile page. + + Renders the full shell (profile.html) with the default tab active. + Tab content is served by AnimalTabView when htmx requests a fragment. + """ + model = Animal template_name = "animals/profile.html" context_object_name = "animal" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - profile = self.request.user.profile - context["now"] = timezone.now().date() - # only for button visibility, do not use as authentication - context["is_owner"] = is_animal_owner(profile, self.object) - context["is_pinned"] = is_pinned(profile, self.object) - context["recent_records"] = recent_records_for(self.object) + base = _base_profile_context(self.request, self.object) + context.update(base) + context["active_tab"] = DEFAULT_TAB_SLUG + context["active_partial"] = TAB_REGISTRY[DEFAULT_TAB_SLUG].template + context.update(TAB_REGISTRY[DEFAULT_TAB_SLUG].build(self.request, self.object, allowed=base["allowed_categories"])) return context def test_func(self): @@ -49,6 +304,58 @@ def test_func(self): return user_can_access_animal(self.request.user.profile, animal) +class AnimalTabView(LoginRequiredMixin, UserPassesTestMixin, DetailView): + """Serves individual tab content for the animal profile page. + + When called with HX-Request header (htmx): returns only the tab fragment. + Without the header (direct navigation / JS disabled): returns the full shell + so progressive enhancement works — every tab has a real fallback URL. + """ + + model = Animal + context_object_name = "animal" + + def _get_tab(self) -> Tab: + slug = self.kwargs.get("slug", "") + tab = TAB_REGISTRY.get(slug) + if tab is None: + raise Http404(f"Unknown tab slug: {slug!r}") + return tab + + def test_func(self): + animal = self.get_object() + profile = self.request.user.profile + if not user_can_access_animal(profile, animal): + return False + tab = TAB_REGISTRY.get(self.kwargs.get("slug", "")) + if tab is None: + return True + if tab.owner_only: + return is_animal_owner(profile, animal) + if tab.categories and not is_animal_owner(profile, animal): + allowed = allowed_categories_for(profile, animal) + return bool(tab.categories & allowed) + return True + + def get_template_names(self): + tab = self._get_tab() + if self.request.headers.get("HX-Request"): + if self.request.GET.get("load_more") and tab.slug in ("vet", "notes"): + return [f"animals/tabs/partials/_timeline_nodes_{tab.slug}.html"] + return [tab.template] + return ["animals/profile.html"] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + tab = self._get_tab() + base = _base_profile_context(self.request, self.object) + context.update(base) + context["active_tab"] = tab.slug + context["active_partial"] = tab.template + context.update(tab.build(self.request, self.object, allowed=base["allowed_categories"])) + return context + + class StableView(LoginRequiredMixin, TemplateView): template_name = "animals/all_animals_stable.html" diff --git a/src/ahc/apps/homepage/templates/homepage/base.html b/src/ahc/apps/homepage/templates/homepage/base.html index 04838f5..a3bd83f 100644 --- a/src/ahc/apps/homepage/templates/homepage/base.html +++ b/src/ahc/apps/homepage/templates/homepage/base.html @@ -1,4 +1,3 @@ -{% load i18n %} {% load static %} <!DOCTYPE html> @@ -6,38 +5,48 @@ <head> <meta charset="UTF-8"> - <title>AHC + {% if title %} + {{ title }} — AHC + {% else %} + AHC app + {% endif %} + + + + - - {% if title %} - {{ title }} - {% else %} - AHC app - {% endif %} - + {% block extra_css %}{% endblock %} -
    -
    -
    + -
    - {% block content %}{% endblock %} +{% block tab_nav %}{% endblock %} + +
    + {% if messages %} + {% for message in messages %} +
    {{ message }}
    + {% endfor %} + {% endif %} + + {% block content %}{% endblock %}
    @@ -45,16 +54,21 @@
    + +
    +
    +

    + +
    + +
    +
    + +{% 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..e675408 100644 --- a/src/ahc/apps/homepage/templates/homepage/homepage.html +++ b/src/ahc/apps/homepage/templates/homepage/homepage.html @@ -4,14 +4,13 @@ {% block content %} -
    -

    Welcome to your pet organizer

    -
    +
    +

    Welcome to your pet organizer

    {% if user.is_authenticated %} -
    +

    Operations:

    @@ -22,12 +21,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 +36,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..867c092 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 = ( @@ -17,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 @@ -41,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): @@ -60,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): @@ -76,7 +90,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 @@ -102,11 +116,10 @@ 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") - 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 @@ -126,7 +139,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/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/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/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/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_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/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 fa913b5..df1cd15 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 @@ -65,6 +125,64 @@ 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), 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", "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") + ) + + def is_note_author(profile, note: MedicalRecord) -> bool: """Return True if the profile is the author of the note.""" return note.author == profile @@ -78,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/create.html b/src/ahc/apps/medical_notes/templates/medical_notes/create.html index 90290bd..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,29 +1,23 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} {% load static %} +{% block extra_js %} + {% if 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 deleted file mode 100644 index 27f7bf6..0000000 --- a/src/ahc/apps/medical_notes/templates/medical_notes/create_notify.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends "homepage/base.html" %} -{% load crispy_forms_tags %} -{% load static %} -{% block content %} -
    -
    - {% if form_name == 'MedicalRecordForm' %} - - {% elif form_name == 'BiometricRecordForm' %} - - {% endif %} - {% csrf_token %} -
    - Register a new note related with: -
    - {{ form|crispy }} -
    -
    - -
    -
    -
    -{# #} -{# 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..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,25 +1,19 @@ {% 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..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 @@ -1,77 +1,33 @@ {% 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 %} -
    -
    -
    -
    - - - {% 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 %} + + {% empty %} +

    No feeding notes found for this record.

    {% endfor %} - {% if notes.has_next %} - Next - Last - {% endif %} + - {% endif %}
    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..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 @@ -1,143 +1,34 @@ {% extends "homepage/base.html" %} {% load static %} +{% block extra_css %} + +{% endblock %} {% block content %} -{% for note, form in notes %} -
    -
    - {{ note.animal.full_name }} - -
    -

    {{ note.short_description }}

    - -
    -
    -

    Appendixes:

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

    Uploaded: {{ attachment.upload_date }}

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

    No description

    - {% else %} -

    {{ attachment.description }}

    - {% endif %} -
    - - -
    - - {% endfor %} -
    - {% csrf_token %} - {{ form.as_p }} - -
    - {% if messages %} -
      - {% for message in messages %} - {{ message }} - {% endfor %} -
    - {% endif %} -
    - -
    - - - {% 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.note_tags.all %} -

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

    - {% endif %} -
    - View full note - Change related animals - {% if note.type_of_event == 'diet_note' %} - View diet -{# Add notification#} - Check notifications - {% endif %} - Delete -
    -
    -
    -{% endfor %} - -
    - - - - {% if paginator.page_range|length > 1 %} -
    - - Pages: - - - {% if page_obj.has_previous %} - First - Previous - {% endif %} - - - {% for num in paginator.page_range %} - {% if page_obj.number == num %} - {{ num }} - {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} - {{ num }} - {% endif %} - {% endfor %} - - {% if page_obj.has_next %} - Next - Last - {% endif %} -
    - {% endif %} +{% if available_months %} +
    + + {% if type_of_event %}{% endif %} + {% if tag_name %}{% endif %} +
    +{% endif %} +
    + {% include "medical_notes/partials/_timeline_page.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 78ddcff..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 @@ -1,115 +1,77 @@ {% 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/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 %} + + +{% for note, form in notes %} +{% ifchanged note.date_creation|date:"Y-m" %} +

    {{ note.date_creation|date:"F Y" }}

    +{% endifchanged %} +
    +
    + {{ note.animal.full_name }} +
    + {{ note.date_creation }} +
    + + Type: + {{ note.type_of_event }} + +
    + +

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

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

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

    + {% endif %} + + +
    +{% endfor %} + +
    + + + {% include "medical_notes/partials/_timeline_pagination.html" %} +
    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 %} + +{% endif %} 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 @@ + + {{ vaccination.vaccine_name }} + {{ vaccination.last_vaccination_date|date:"Y-m-d"|default:"—" }} + {{ vaccination.valid_until|date:"Y-m-d"|default:"—" }} + {{ vaccination.suggested_clinic|default:"—" }} + {{ vaccination.reminder_date|date:"Y-m-d"|default:"—" }} + + Edit + Delete + + 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 %} + +{% else %} + +{% endif %} + {% csrf_token %} + + {{ form.vaccine_name }} + {% if form.vaccine_name.errors %}{{ form.vaccine_name.errors|join:" " }}{% endif %} + + + {{ form.last_vaccination_date }} + {% if form.last_vaccination_date.errors %}{{ form.last_vaccination_date.errors|join:" " }}{% endif %} + + + {{ form.valid_until }} + {% if form.valid_until.errors %}{{ form.valid_until.errors|join:" " }}{% endif %} + + + {{ form.suggested_clinic }} + {% if form.suggested_clinic.errors %}{{ form.suggested_clinic.errors|join:" " }}{% endif %} + + + {{ form.reminder_date }} + {% if form.reminder_date.errors %}{{ form.reminder_date.errors|join:" " }}{% endif %} + + + {% if is_new %} + + {% else %} + + Cancel + {% endif %} + + 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/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("/create/", notes_views.CreateNoteFormView.as_view(), name="note_create"), @@ -29,4 +30,17 @@ notes_views.DownloadAttachmentView.as_view(), name="attachment_download", ), + path("/vaccination/add/", vaccination_views.VaccinationAddView.as_view(), name="vaccination_add"), + path("/vaccination/edit/", vaccination_views.VaccinationEditView.as_view(), name="vaccination_edit"), + path("/vaccination/save/", vaccination_views.VaccinationSaveView.as_view(), name="vaccination_save"), + path( + "/vaccination/cancel/", + vaccination_views.VaccinationCancelView.as_view(), + name="vaccination_cancel", + ), + path( + "/vaccination/delete/", + vaccination_views.VaccinationDeleteView.as_view(), + name="vaccination_delete", + ), ] 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..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 ( @@ -41,6 +43,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 +57,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") @@ -70,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 @@ -119,12 +172,9 @@ 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)}") - - return redirect(request.path) + messages.error(request, f"Failed to upload: {', '.join(str(e) for e in errors)}") - 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): @@ -133,6 +183,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 +206,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/src/ahc/apps/medical_notes/views/type_feeding_notes.py b/src/ahc/apps/medical_notes/views/type_feeding_notes.py index c585571..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")) @@ -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/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/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 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/src/ahc/apps/users/forms.py b/src/ahc/apps/users/forms.py index acc0b2f..a8f598e 100644 --- a/src/ahc/apps/users/forms.py +++ b/src/ahc/apps/users/forms.py @@ -1,6 +1,8 @@ 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.animals.models import ShareDefaults from ahc.apps.users.models import Profile @@ -24,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/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/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..6b9ea7a 100644 --- a/src/ahc/apps/users/templates/users/login_success.html +++ b/src/ahc/apps/users/templates/users/login_success.html @@ -1,24 +1,4 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} {% block content %} -
    -
    - -
    - -

    {{ user.email }}

    -
    -
    -
    - {% csrf_token %} -
    - Profile info! - {{ user_form|crispy }} - {{ profile_update }} -
    -
    - -
    -
    -
    + {% include "partials/user_profile_section.html" %} {% 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_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_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/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 df44e9f..6b9ea7a 100644 --- a/src/ahc/apps/users/templates/users/profile.html +++ b/src/ahc/apps/users/templates/users/profile.html @@ -1,24 +1,4 @@ {% extends "homepage/base.html" %} -{% load crispy_forms_tags %} {% block content %} -
    -
    - -
    - -

    {{ user.email }}

    -
    -
    -
    - {% csrf_token %} -
    - Profile info! - {{ user_form|crispy }} - {{ profile_update }} -
    -
    - -
    -
    -
    + {% include "partials/user_profile_section.html" %} {% 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/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 %} +

    Default sharing settings

    +

    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.

    + +
    + {% csrf_token %} + {% include "partials/form_fields.html" %} + +
    + + {% if messages %} + {% for message in messages %} +

    {{ message }}

    + {% 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/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/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. 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/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..ae4bef8 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,4 +11,347 @@ --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); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +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; + 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); +} + +#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; + letter-spacing: -0.02em; +} + +/* Centered section/page headings */ +.welcome-heading { + text-align: center; +} + +/* 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 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; + 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; +} + +/* 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/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..e103448 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(220px, 1fr)); grid-gap: 20px; } diff --git a/static/css/timeline.css b/static/css/timeline.css index f15adda..9f57006 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,13 @@ body { .timeline .info div { margin-top: 10px; - color: var(--crystal); - + color: var(--timeline-crystal); } - -.timeline .info a { - display: block; - width: fit-content; - margin: 40px auto; - text-decoration: none; - background: black; +.records-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; } .timeline ol::-webkit-scrollbar { @@ -139,11 +94,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 +107,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 +116,7 @@ body { list-style-type: none; width: 160px; height: 5px; - background: var(--white); + background: var(--timeline-white); scroll-snap-align: start; } @@ -183,7 +138,7 @@ body { height: 16px; transform: translateY(-50%); border-radius: 50%; - background: var(--midnight-green); + background: var(--timeline-midnight-green); z-index: 1; } @@ -194,8 +149,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 +173,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 +183,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 +191,46 @@ body { font-size: 1.4rem; font-weight: bold; margin-bottom: 8px; - color: var(--midnight-green); + 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/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 803da26..f0e6a99 100644 --- a/static/js/expanding_sections.js +++ b/static/js/expanding_sections.js @@ -1,16 +1,33 @@ -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"); - 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 = 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", 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 59e0d7e..95997ec 100644 --- a/static/js/hiding_note_fields_in_form.js +++ b/static/js/hiding_note_fields_in_form.js @@ -1,53 +1,47 @@ -document.addEventListener('DOMContentLoaded', function() { +// Conditional field visibility for MedicalRecordForm. +// Exposed as window.initNoteForm so modal.js can re-run it after htmx swaps. + +(function () { + "use strict"; + 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'); - - 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'; + var typeOfEventField = document.getElementById('id_type_of_event'); + if (!typeOfEventField) return; + + 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'); + + 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') { + 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(); + window.initNoteForm = function initNoteForm() { + var typeOfEventField = document.getElementById('id_type_of_event'); + if (!typeOfEventField) return; + handleTypeOfEventChange(); + typeOfEventField.addEventListener('change', handleTypeOfEventChange); + }; - const typeOfEventField = document.getElementById('id_type_of_event'); - typeOfEventField.addEventListener('change', handleTypeOfEventChange); -}); + document.addEventListener('DOMContentLoaded', window.initNoteForm); +}()); 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/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 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 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 d0c0baf..67e34d4 100644 --- a/static/js/pin_animal.js +++ b/static/js/pin_animal.js @@ -1,47 +1,55 @@ -const link = document.getElementById('togglePinnedButton'); - -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 response = await fetch(link.href, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': getCookie('csrftoken'), - }, - body: JSON.stringify({animal_id: animalId, action: action}), - }); +// 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. - if (!response.ok) { - throw new Error('Request failed: ' + response.statusText); - } +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"; - link.dataset.action = newAction; - link.innerText = (newAction === 'add') ? 'Add to Pinned' : 'Remove from Pinned'; + 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, + }); - const result = await response.json(); - console.log(result); // Check the result in the console - } catch (error) { - console.error('Error:', error); - } -}); + 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..6ea800b --- /dev/null +++ b/static/js/tabs.js @@ -0,0 +1,51 @@ +// 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//tab// + 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(); + } + if (typeof initTimelineJump === "function") { + initTimelineJump(); + } + if (typeof initVaccinationTable === "function") { + initVaccinationTable(); + } + }); +}()); 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/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

    + var anchor = document.getElementById("month-" + month); + if (anchor) { + anchor.scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + + // Tab horizontal timelines: node anchors follow the pattern "tlmonth--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); 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 element for idempotency: +// - fresh table load (new DOM element) → no flag → init runs and attaches listeners +// - row-level swap (same
    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); }); +} 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(/]*)?>[\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('");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=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"){t--}}if(n0){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=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;e0){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","
    ");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(n0}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;t0){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;e0){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;e0){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", "+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;eQ.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=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;en.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;e0?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;t0){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"," ."+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;} ")}}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/animal_card.html b/templates/partials/animal_card.html index 37c132d..18e9018 100644 --- a/templates/partials/animal_card.html +++ b/templates/partials/animal_card.html @@ -1,5 +1,11 @@ -{{ animal.full_name }} + aria-label="Go to profile of {{ animal.full_name }}" +> +
    + {% if animal.profile_image %} + + {% endif %} +
    + {{ animal.full_name }} + diff --git a/templates/partials/form_fields.html b/templates/partials/form_fields.html new file mode 100644 index 0000000..7a27877 --- /dev/null +++ b/templates/partials/form_fields.html @@ -0,0 +1,22 @@ +{% if form.non_field_errors %} +
    + {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} +
    +{% endif %} +{% 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/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 @@ +
    + {% csrf_token %} +
    + {{ legend }} + {% include "partials/form_fields.html" %} +
    + + 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 %} +
    + + +
    diff --git a/uv.lock b/uv.lock index c3d6059..0646ed7 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" }, @@ -120,6 +117,8 @@ dependencies = [ dev = [ { name = "bandit" }, { name = "codespell" }, + { name = "commitizen" }, + { name = "django-stubs" }, { name = "icecream" }, { name = "pre-commit" }, { name = "pytest" }, @@ -133,13 +132,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" }, @@ -159,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]] @@ -439,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" @@ -478,19 +508,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" @@ -544,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" @@ -553,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" @@ -602,27 +640,31 @@ wheels = [ ] [[package]] -name = "django-bootstrap-modal-forms" -version = "3.0.5" +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/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" } +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/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" }, + { 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-crispy-forms" -version = "2.6" +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/73/42/c2cfb672493730b963ef377b103e29871c56348a215d0ae8cf362fe8ab1e/django_crispy_forms-2.6.tar.gz", hash = "sha256:4921a1087c6cd4f9fa3c139654c1de1c1c385f8bd6729aaee530bc0121ab4b93", size = 1097838, upload-time = "2026-03-01T09:03:37.138Z" } +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/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" }, + { 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]] @@ -787,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" @@ -814,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" @@ -973,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]] @@ -1211,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" @@ -1313,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" @@ -1355,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" @@ -1418,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"