Initial Commit
slip slop
This commit is contained in:
commit
064e672ab7
61 changed files with 3504 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
__pycache__
|
||||||
|
.env
|
||||||
121
Makefile
Normal file
121
Makefile
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# ── repertory-api Makefile ────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# make local — first-time (or repeated) local dev setup:
|
||||||
|
# copies env.example → .env if missing,
|
||||||
|
# starts Postgres via Docker Compose,
|
||||||
|
# waits for it to be ready,
|
||||||
|
# runs Alembic migrations,
|
||||||
|
# starts the API container.
|
||||||
|
#
|
||||||
|
# make stop — stop all Docker Compose services (data is preserved).
|
||||||
|
# make reset — stop services AND wipe the Postgres volume (fresh slate).
|
||||||
|
# make migrate — run pending Alembic migrations against the local DB.
|
||||||
|
# make test — run the pytest suite (SQLite in-memory, no Docker needed).
|
||||||
|
# make logs — tail Docker Compose logs.
|
||||||
|
# make shell — open a psql shell in the running Postgres container.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
COMPOSE = docker compose -f docker/docker-compose.yml --env-file .env
|
||||||
|
ALEMBIC = PYTHONPATH=. uv run alembic
|
||||||
|
PYTEST = PYTHONPATH=. uv run pytest
|
||||||
|
PY = uv run python
|
||||||
|
|
||||||
|
DB_HOST = localhost
|
||||||
|
DB_USER = repertory
|
||||||
|
DB_NAME = tunes
|
||||||
|
|
||||||
|
.PHONY: help local stop reset migrate test logs shell sync
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
help:
|
||||||
|
@echo ""
|
||||||
|
@echo " make local — set up and start local dev environment"
|
||||||
|
@echo " make stop — stop Docker services"
|
||||||
|
@echo " make reset — stop services and wipe database volume"
|
||||||
|
@echo " make migrate — run pending Alembic migrations"
|
||||||
|
@echo " make test — run test suite (no Docker required)"
|
||||||
|
@echo " make logs — tail Docker Compose logs"
|
||||||
|
@echo " make shell — open a psql shell in the Postgres container"
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
# ── Sync dependencies ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sync:
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# ── Local dev ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
local: sync _copy_env _db_up _wait_for_db migrate _api_up
|
||||||
|
@echo ""
|
||||||
|
@echo " ✅ Local environment is ready."
|
||||||
|
@echo ""
|
||||||
|
@set -a; . ./.env; set +a; \
|
||||||
|
echo " API: http://localhost:$${API_PORT:-5000}"
|
||||||
|
@echo " API key: $$(grep '^API_KEY=' .env | cut -d= -f2)"
|
||||||
|
@echo ""
|
||||||
|
@echo " Run 'make logs' to tail the logs."
|
||||||
|
@echo " Run 'make stop' to stop services."
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
_copy_env:
|
||||||
|
@if [ ! -f .env ]; then \
|
||||||
|
cp env.example .env; \
|
||||||
|
echo " 📋 Copied env.example → .env"; \
|
||||||
|
echo " API key is set to: $$(grep '^API_KEY=' .env | cut -d= -f2)"; \
|
||||||
|
echo " Edit .env to change it before deploying to production."; \
|
||||||
|
else \
|
||||||
|
echo " ✔ .env already exists — skipping copy."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
_db_up:
|
||||||
|
@echo " 🐳 Starting Postgres…"
|
||||||
|
$(COMPOSE) up -d db
|
||||||
|
|
||||||
|
_wait_for_db:
|
||||||
|
@echo " ⏳ Waiting for Postgres to be ready…"
|
||||||
|
@set -a; . ./.env; set +a; \
|
||||||
|
for i in $$(seq 1 30); do \
|
||||||
|
if pg_isready -U $(DB_USER) -d $(DB_NAME) -h $(DB_HOST) -p $${DB_PORT:-5432} \
|
||||||
|
> /dev/null 2>&1; then \
|
||||||
|
echo " ✔ Postgres is ready."; \
|
||||||
|
break; \
|
||||||
|
fi; \
|
||||||
|
if [ $$i -eq 30 ]; then \
|
||||||
|
echo " ❌ Timed out waiting for Postgres."; exit 1; \
|
||||||
|
fi; \
|
||||||
|
sleep 1; \
|
||||||
|
done
|
||||||
|
|
||||||
|
_api_up:
|
||||||
|
@echo " 🐳 Starting API container…"
|
||||||
|
$(COMPOSE) up -d --build api
|
||||||
|
|
||||||
|
# ── Migrations ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
migrate: sync
|
||||||
|
@echo " 🗄 Running Alembic migrations…"
|
||||||
|
@set -a; . ./.env; set +a; $(ALEMBIC) upgrade head
|
||||||
|
@echo " ✔ Migrations complete."
|
||||||
|
|
||||||
|
# ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Uses SQLite in-memory — no Docker or network required.
|
||||||
|
|
||||||
|
test: sync
|
||||||
|
@echo " 🧪 Running tests…"
|
||||||
|
$(PYTEST) tests/ -v
|
||||||
|
|
||||||
|
# ── Utilities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
stop:
|
||||||
|
$(COMPOSE) stop
|
||||||
|
|
||||||
|
reset:
|
||||||
|
@echo " ⚠️ Stopping services and removing database volume…"
|
||||||
|
$(COMPOSE) down -v
|
||||||
|
@echo " ✔ Done. Run 'make local' to start fresh."
|
||||||
|
|
||||||
|
logs:
|
||||||
|
$(COMPOSE) logs -f
|
||||||
|
|
||||||
|
shell:
|
||||||
|
$(COMPOSE) exec db psql -U $(DB_USER) -d $(DB_NAME)
|
||||||
216
README.md
Normal file
216
README.md
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
# repertory-api
|
||||||
|
|
||||||
|
Flask + SQLAlchemy REST API for the Repertory tune-tracking database.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Flask 3** – HTTP API
|
||||||
|
- **SQLAlchemy 2 / Flask-SQLAlchemy** – ORM
|
||||||
|
- **PostgreSQL** – database (`tunes`)
|
||||||
|
- **Alembic** – migrations
|
||||||
|
- **uv** – dependency management
|
||||||
|
- **Docker Compose** – local development
|
||||||
|
- **NixOS systemd module** – production deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
### First time setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make local
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Copy `env.example` → `.env` (skipped if `.env` already exists)
|
||||||
|
2. Start Postgres via Docker Compose
|
||||||
|
3. Wait for Postgres to be ready
|
||||||
|
4. Run Alembic migrations
|
||||||
|
5. Start the API container
|
||||||
|
|
||||||
|
At the end it prints the API URL and your local API key:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Local environment is ready.
|
||||||
|
|
||||||
|
API: http://localhost:5000
|
||||||
|
API key: dev-secret-key-change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
The API key is set in `.env` as `API_KEY`. Change it there if you want — it only affects your local dev environment. Production keys are managed via SOPS (see below).
|
||||||
|
|
||||||
|
### Subsequent runs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make local # idempotent — safe to re-run, skips what's already done
|
||||||
|
make stop # stop containers (data preserved)
|
||||||
|
make reset # stop containers AND wipe the database volume (fresh slate)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other useful commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # run the test suite (SQLite in-memory, no Docker required)
|
||||||
|
make migrate # run pending Alembic migrations against the local DB
|
||||||
|
make logs # tail Docker Compose logs
|
||||||
|
make shell # open a psql shell in the running Postgres container
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `make` or `make help` to see all available targets.
|
||||||
|
|
||||||
|
### Adding a schema migration
|
||||||
|
|
||||||
|
After changing a model in `app/models.py`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With the local DB running:
|
||||||
|
uv run alembic revision --autogenerate -m "describe your change"
|
||||||
|
make migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit both the model change and the new file in `migrations/versions/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|-----------|--------------|
|
||||||
|
| GET | `/health` | Health check |
|
||||||
|
|
||||||
|
### Tunes
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|---------------|----------------------------------------------------------|
|
||||||
|
| GET | `/tunes/` | List all tunes (supports query filters — see below) |
|
||||||
|
| GET | `/tunes/<id>` | Get a single tune with all sub-records |
|
||||||
|
| POST | `/tunes/` | Create a tune (optionally with nested banjo/fiddle/etc.) |
|
||||||
|
| PATCH | `/tunes/<id>` | Update tune core fields |
|
||||||
|
| DELETE | `/tunes/<id>` | Delete a tune (cascades to all sub-records) |
|
||||||
|
|
||||||
|
**GET `/tunes/` query params** (all optional, combinable):
|
||||||
|
|
||||||
|
| Param | Example | Notes |
|
||||||
|
|--------------|----------------------|------------------------------------|
|
||||||
|
| `search` | `?search=cluck` | Substring match on name |
|
||||||
|
| `key` | `?key=D` | Exact key match (case-insensitive) |
|
||||||
|
| `instrument` | `?instrument=fiddle` | Filter by instrument |
|
||||||
|
| `tuning` | `?tuning=cross` | Substring match on tuning |
|
||||||
|
| `callable` | `?callable=true` | Boolean |
|
||||||
|
| `review` | `?review=false` | Boolean |
|
||||||
|
| `to_learn` | `?to_learn=true` | Boolean |
|
||||||
|
| `difficulty` | `?difficulty=easy` | "easy" or "hard" |
|
||||||
|
|
||||||
|
`instrument` must be set for `tuning`, `callable`, `review`, `to_learn`, and `difficulty` filters to apply.
|
||||||
|
|
||||||
|
### Instrument sub-records (banjo / fiddle)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|-----------------------|-------------------------------------|
|
||||||
|
| PUT | `/tunes/<id>/banjo` | Create or fully replace banjo entry |
|
||||||
|
| PUT | `/tunes/<id>/fiddle` | Create or fully replace fiddle entry|
|
||||||
|
| PATCH | `/tunes/<id>/banjo` | Partially update banjo entry |
|
||||||
|
| PATCH | `/tunes/<id>/fiddle` | Partially update fiddle entry |
|
||||||
|
| DELETE | `/tunes/<id>/banjo` | Remove banjo entry |
|
||||||
|
| DELETE | `/tunes/<id>/fiddle` | Remove fiddle entry |
|
||||||
|
|
||||||
|
Instrument fields: `learned_from`, `date_learned` (ISO date string), `tuning`, `callable` (bool), `review` (bool), `to_learn` (bool), `difficulty` ("easy"/"hard").
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|-----------------------------------|---------------|
|
||||||
|
| GET | `/tunes/<id>/notes` | List notes |
|
||||||
|
| POST | `/tunes/<id>/notes` | Add a note |
|
||||||
|
| PATCH | `/tunes/<id>/notes/<note_id>` | Update a note |
|
||||||
|
| DELETE | `/tunes/<id>/notes/<note_id>` | Delete a note |
|
||||||
|
|
||||||
|
### References
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|----------------------------------------------------|--------------------|
|
||||||
|
| GET | `/tunes/<id>/references` | List references |
|
||||||
|
| POST | `/tunes/<id>/references` | Add a reference |
|
||||||
|
| PATCH | `/tunes/<id>/references/<ref_id>` | Update a reference |
|
||||||
|
| DELETE | `/tunes/<id>/references/<ref_id>` | Delete a reference |
|
||||||
|
| POST | `/tunes/<id>/references/<ref_id>/musicians` | Add a musician tag |
|
||||||
|
| DELETE | `/tunes/<id>/references/<ref_id>/musicians/<name>` | Remove a musician |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production deployment (NixOS + SOPS + age)
|
||||||
|
|
||||||
|
The NixOS module in [`nixos/module.nix`](nixos/module.nix) fetches the application source directly from your Forgejo instance at a pinned tag, builds the Python environment, and manages the service under systemd — no manual file copying required.
|
||||||
|
|
||||||
|
### 1. Add secrets to your SOPS file
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
repertory-api-key: "your-api-key-here"
|
||||||
|
repertory-db-url: "postgresql://user:pass@host:5432/tunes"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Wire up the module in `configuration.nix`
|
||||||
|
|
||||||
|
```nix
|
||||||
|
imports = [ (fetchTarball {
|
||||||
|
url = "https://git.yourdomain.com/yourusername/repertory-api/archive/v1.0.0.tar.gz";
|
||||||
|
sha256 = "0000000000000000000000000000000000000000000000000000";
|
||||||
|
} + "/nixos/module.nix") ];
|
||||||
|
|
||||||
|
sops.secrets."repertory-api-key" = {};
|
||||||
|
sops.secrets."repertory-db-url" = {};
|
||||||
|
|
||||||
|
services.repertory-api = {
|
||||||
|
enable = true;
|
||||||
|
domain = "git.yourdomain.com";
|
||||||
|
owner = "yourusername";
|
||||||
|
version = "v1.0.0";
|
||||||
|
rev = "abc123..."; # git commit SHA for the tag
|
||||||
|
sha256 = "sha256-..."; # see below
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nixos-rebuild switch
|
||||||
|
```
|
||||||
|
|
||||||
|
On start, the service automatically runs `alembic upgrade head` before Flask comes up — so migrations are applied on every deploy with no manual step.
|
||||||
|
|
||||||
|
### Updating to a new version
|
||||||
|
|
||||||
|
1. Push a new tag to Forgejo (e.g. `v1.1.0`)
|
||||||
|
2. Get the new sha256:
|
||||||
|
```bash
|
||||||
|
nix-prefetch-url --unpack \
|
||||||
|
https://git.yourdomain.com/yourusername/repertory-api/archive/v1.1.0.tar.gz
|
||||||
|
```
|
||||||
|
3. Update `version`, `rev`, and `sha256` in `configuration.nix`
|
||||||
|
4. `nixos-rebuild switch` — Nix fetches the new source, rebuilds the Python env, restarts the service, and runs any new migrations
|
||||||
|
|
||||||
|
### How secrets are handled
|
||||||
|
|
||||||
|
The `preStart` script reads the SOPS-decrypted secret files (written to `/run/secrets/` by sops-nix) and assembles them into a mode-600 env file at `/run/repertory-api/env` on tmpfs. The Flask process receives `API_KEY` and `DATABASE_URL` from that file. Nothing is written to disk in plaintext.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database schema
|
||||||
|
|
||||||
|
```
|
||||||
|
musicians (id, name)
|
||||||
|
sources (id, name)
|
||||||
|
tunings (id, name)
|
||||||
|
instruments (id, name)
|
||||||
|
tunes (id, name, key, modal, source_id → sources.id)
|
||||||
|
├── tune_by_instrument (id, tune_id, instrument_id, learned_from_id, tuning_id, date_learned, callable, review, to_learn, difficulty)
|
||||||
|
│ └── tune_by_instrument_notes (id, tune_by_instrument_id, note)
|
||||||
|
├── tune_notes (id, tune_id, note)
|
||||||
|
└── references (id, tune_id, link, site)
|
||||||
|
└── reference_musicians (reference_id, musician_id) ← join table
|
||||||
|
```
|
||||||
|
|
||||||
|
To add a new instrument (e.g. harmonica): add a model in `app/models.py` mirroring `Banjo`/`Fiddle`, register the name in `INSTRUMENT_MODELS` in `app/routes_instruments.py`, then `uv run alembic revision --autogenerate -m "add harmonica"` and `make migrate`.
|
||||||
132
ain/README.md
Normal file
132
ain/README.md
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
# ain templates
|
||||||
|
|
||||||
|
[ain](https://github.com/jonaslu/ain) is a terminal HTTP client. These templates cover every API endpoint.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Install ain
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install ain # macOS
|
||||||
|
# or: go install github.com/jonaslu/ain/cmd/ain@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
ain requires `curl`, `wget`, or `httpie` to be available — `curl` is already on macOS.
|
||||||
|
|
||||||
|
### 2. Configure connection details
|
||||||
|
|
||||||
|
ain picks up variables from the project root `.env` automatically when run from the project root. `make local` will have already created that file for you. The relevant variables are:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
API_KEY=dev-secret-key-change-me
|
||||||
|
REPERTORY_HOST=http://localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run a template
|
||||||
|
|
||||||
|
Every template is run by combining `base.ain` with a specific template file. `base.ain` provides the host and auth header; the specific file adds the path, method, and body.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ain ain/base.ain ain/health.ain
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ain ain/base.ain ain/health.ain
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tunes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all tunes
|
||||||
|
ain ain/base.ain ain/tunes/list.ain
|
||||||
|
|
||||||
|
# List with filters (edit the [Query] section in the file first)
|
||||||
|
ain ain/base.ain ain/tunes/list-filtered.ain
|
||||||
|
|
||||||
|
# Get a single tune
|
||||||
|
ain ain/base.ain ain/tunes/get.ain --vars ID=1
|
||||||
|
|
||||||
|
# Create a tune (edit the [Body] in the file first)
|
||||||
|
ain ain/base.ain ain/tunes/create.ain
|
||||||
|
|
||||||
|
# Update core fields
|
||||||
|
ain ain/base.ain ain/tunes/update.ain --vars ID=1
|
||||||
|
|
||||||
|
# Delete a tune
|
||||||
|
ain ain/base.ain ain/tunes/delete.ain --vars ID=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instruments (banjo / fiddle)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create or replace a banjo entry
|
||||||
|
ain ain/base.ain ain/instruments/upsert-banjo.ain --vars ID=1
|
||||||
|
|
||||||
|
# Create or replace a fiddle entry
|
||||||
|
ain ain/base.ain ain/instruments/upsert-fiddle.ain --vars ID=1
|
||||||
|
|
||||||
|
# Patch individual banjo fields
|
||||||
|
ain ain/base.ain ain/instruments/patch-banjo.ain --vars ID=1
|
||||||
|
|
||||||
|
# Patch individual fiddle fields
|
||||||
|
ain ain/base.ain ain/instruments/patch-fiddle.ain --vars ID=1
|
||||||
|
|
||||||
|
# Delete banjo entry
|
||||||
|
ain ain/base.ain ain/instruments/delete-banjo.ain --vars ID=1
|
||||||
|
|
||||||
|
# Delete fiddle entry
|
||||||
|
ain ain/base.ain ain/instruments/delete-fiddle.ain --vars ID=1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List notes for a tune
|
||||||
|
ain ain/base.ain ain/notes/list.ain --vars ID=1
|
||||||
|
|
||||||
|
# Add a note
|
||||||
|
ain ain/base.ain ain/notes/create.ain --vars ID=1
|
||||||
|
|
||||||
|
# Update a note
|
||||||
|
ain ain/base.ain ain/notes/update.ain --vars ID=1 NOTE_ID=2
|
||||||
|
|
||||||
|
# Delete a note
|
||||||
|
ain ain/base.ain ain/notes/delete.ain --vars ID=1 NOTE_ID=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### References
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List references for a tune
|
||||||
|
ain ain/base.ain ain/references/list.ain --vars ID=1
|
||||||
|
|
||||||
|
# Add a reference (with optional musicians in the body)
|
||||||
|
ain ain/base.ain ain/references/create.ain --vars ID=1
|
||||||
|
|
||||||
|
# Update a reference
|
||||||
|
ain ain/base.ain ain/references/update.ain --vars ID=1 REF_ID=2
|
||||||
|
|
||||||
|
# Delete a reference
|
||||||
|
ain ain/base.ain ain/references/delete.ain --vars ID=1 REF_ID=2
|
||||||
|
|
||||||
|
# Add a musician to a reference
|
||||||
|
ain ain/base.ain ain/references/add-musician.ain --vars ID=1 REF_ID=2
|
||||||
|
|
||||||
|
# Remove a musician from a reference
|
||||||
|
ain ain/base.ain ain/references/delete-musician.ain --vars ID=1 REF_ID=2 NAME="Tony Rice"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- **Print without sending**: `ain -p ain/base.ain ain/tunes/list.ain` — shows the curl command ain would run, useful for debugging
|
||||||
|
- **Pipe output**: `ain ain/base.ain ain/tunes/list.ain | jq .` — pipe straight into jq or anything else
|
||||||
|
- **Multiple filters**: edit `ain/tunes/list-filtered.ain` and remove the query params you don't need before running
|
||||||
|
- **Musician names with spaces**: quote the whole `--vars` value: `--vars NAME="Doc Watson"`
|
||||||
15
ain/base.ain
Normal file
15
ain/base.ain
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# ain/base.ain
|
||||||
|
# Shared host and auth header — included by every other template.
|
||||||
|
# Variables are loaded from the project root .env automatically when
|
||||||
|
# ain is run from the project root.
|
||||||
|
# See env.example for the full list of variables.
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
${REPERTORY_HOST}
|
||||||
|
|
||||||
|
[Headers]
|
||||||
|
X-API-Key: ${API_KEY}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
[Backend]
|
||||||
|
curl
|
||||||
8
ain/health.ain
Normal file
8
ain/health.ain
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Health check
|
||||||
|
# ain ain/base.ain ain/health.ain
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/health
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
GET
|
||||||
8
ain/instruments/delete-banjo.ain
Normal file
8
ain/instruments/delete-banjo.ain
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Delete a banjo entry for a tune
|
||||||
|
# ain ain/base.ain ain/instruments/delete-banjo.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/banjo
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
DELETE
|
||||||
8
ain/instruments/delete-fiddle.ain
Normal file
8
ain/instruments/delete-fiddle.ain
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Delete a fiddle entry for a tune
|
||||||
|
# ain ain/base.ain ain/instruments/delete-fiddle.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/fiddle
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
DELETE
|
||||||
14
ain/instruments/patch-banjo.ain
Normal file
14
ain/instruments/patch-banjo.ain
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Partially update a banjo entry — only include fields you want to change
|
||||||
|
# ain ain/base.ain ain/instruments/patch-banjo.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/banjo
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
PATCH
|
||||||
|
|
||||||
|
[Body]
|
||||||
|
{
|
||||||
|
"callable": true,
|
||||||
|
"review": false
|
||||||
|
}
|
||||||
14
ain/instruments/patch-fiddle.ain
Normal file
14
ain/instruments/patch-fiddle.ain
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Partially update a fiddle entry — only include fields you want to change
|
||||||
|
# ain ain/base.ain ain/instruments/patch-fiddle.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/fiddle
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
PATCH
|
||||||
|
|
||||||
|
[Body]
|
||||||
|
{
|
||||||
|
"tuning": "GDAD",
|
||||||
|
"to_learn": false
|
||||||
|
}
|
||||||
19
ain/instruments/upsert-banjo.ain
Normal file
19
ain/instruments/upsert-banjo.ain
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Create or fully replace a banjo entry for a tune
|
||||||
|
# ain ain/base.ain ain/instruments/upsert-banjo.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/banjo
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
PUT
|
||||||
|
|
||||||
|
[Body]
|
||||||
|
{
|
||||||
|
"tuning": "double C",
|
||||||
|
"learned_from": "Earl Scruggs",
|
||||||
|
"date_learned": "2024-03-01",
|
||||||
|
"callable": true,
|
||||||
|
"review": false,
|
||||||
|
"to_learn": false,
|
||||||
|
"difficulty": null
|
||||||
|
}
|
||||||
19
ain/instruments/upsert-fiddle.ain
Normal file
19
ain/instruments/upsert-fiddle.ain
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Create or fully replace a fiddle entry for a tune
|
||||||
|
# ain ain/base.ain ain/instruments/upsert-fiddle.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/fiddle
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
PUT
|
||||||
|
|
||||||
|
[Body]
|
||||||
|
{
|
||||||
|
"tuning": "cross",
|
||||||
|
"learned_from": "Alison Krauss",
|
||||||
|
"date_learned": "2024-06-15",
|
||||||
|
"callable": false,
|
||||||
|
"review": true,
|
||||||
|
"to_learn": false,
|
||||||
|
"difficulty": null
|
||||||
|
}
|
||||||
13
ain/notes/create.ain
Normal file
13
ain/notes/create.ain
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Add a note to a tune
|
||||||
|
# ain ain/base.ain ain/notes/create.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/notes
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
POST
|
||||||
|
|
||||||
|
[Body]
|
||||||
|
{
|
||||||
|
"note": "tricky B part — watch the timing"
|
||||||
|
}
|
||||||
8
ain/notes/delete.ain
Normal file
8
ain/notes/delete.ain
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Delete a note
|
||||||
|
# ain ain/base.ain ain/notes/delete.ain --vars ID=1 NOTE_ID=2
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/notes/${NOTE_ID}
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
DELETE
|
||||||
8
ain/notes/list.ain
Normal file
8
ain/notes/list.ain
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# List all notes for a tune
|
||||||
|
# ain ain/base.ain ain/notes/list.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/notes
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
GET
|
||||||
13
ain/notes/update.ain
Normal file
13
ain/notes/update.ain
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Update a note
|
||||||
|
# ain ain/base.ain ain/notes/update.ain --vars ID=1 NOTE_ID=2
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/notes/${NOTE_ID}
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
PATCH
|
||||||
|
|
||||||
|
[Body]
|
||||||
|
{
|
||||||
|
"note": "updated note text"
|
||||||
|
}
|
||||||
13
ain/references/add-musician.ain
Normal file
13
ain/references/add-musician.ain
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Add a musician tag to a reference
|
||||||
|
# ain ain/base.ain ain/references/add-musician.ain --vars ID=1 REF_ID=2
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/references/${REF_ID}/musicians
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
POST
|
||||||
|
|
||||||
|
[Body]
|
||||||
|
{
|
||||||
|
"name": "Tony Rice"
|
||||||
|
}
|
||||||
18
ain/references/create.ain
Normal file
18
ain/references/create.ain
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Add a reference to a tune (with optional musicians)
|
||||||
|
# ain ain/base.ain ain/references/create.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/references
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
POST
|
||||||
|
|
||||||
|
[Body]
|
||||||
|
{
|
||||||
|
"link": "https://www.youtube.com/watch?v=example",
|
||||||
|
"site": "YouTube",
|
||||||
|
"musicians": [
|
||||||
|
{ "name": "Doc Watson" },
|
||||||
|
{ "name": "Merle Watson" }
|
||||||
|
]
|
||||||
|
}
|
||||||
8
ain/references/delete-musician.ain
Normal file
8
ain/references/delete-musician.ain
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Remove a musician tag from a reference
|
||||||
|
# ain ain/base.ain ain/references/delete-musician.ain --vars ID=1 REF_ID=2 NAME="Tony Rice"
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/references/${REF_ID}/musicians/${NAME}
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
DELETE
|
||||||
8
ain/references/delete.ain
Normal file
8
ain/references/delete.ain
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Delete a reference (cascades to its musicians)
|
||||||
|
# ain ain/base.ain ain/references/delete.ain --vars ID=1 REF_ID=2
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/references/${REF_ID}
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
DELETE
|
||||||
8
ain/references/list.ain
Normal file
8
ain/references/list.ain
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# List all references for a tune
|
||||||
|
# ain ain/base.ain ain/references/list.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/references
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
GET
|
||||||
14
ain/references/update.ain
Normal file
14
ain/references/update.ain
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Update a reference (link and/or site)
|
||||||
|
# ain ain/base.ain ain/references/update.ain --vars ID=1 REF_ID=2
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}/references/${REF_ID}
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
PATCH
|
||||||
|
|
||||||
|
[Body]
|
||||||
|
{
|
||||||
|
"site": "TuneArch",
|
||||||
|
"link": "https://tunearchy.com/example"
|
||||||
|
}
|
||||||
46
ain/tunes/create.ain
Normal file
46
ain/tunes/create.ain
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Create a tune with nested banjo, fiddle, notes, and references in one shot.
|
||||||
|
# All fields except the top-level ones are optional — remove any block you don't need.
|
||||||
|
# ain ain/base.ain ain/tunes/create.ain
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
POST
|
||||||
|
|
||||||
|
[Body]
|
||||||
|
{
|
||||||
|
"name": "Cluck Old Hen",
|
||||||
|
"key": "A",
|
||||||
|
"version": "full",
|
||||||
|
"banjo": {
|
||||||
|
"tuning": "double C",
|
||||||
|
"learned_from": "Earl Scruggs",
|
||||||
|
"date_learned": "2024-03-01",
|
||||||
|
"callable": true,
|
||||||
|
"review": false,
|
||||||
|
"to_learn": false,
|
||||||
|
"difficulty": null
|
||||||
|
},
|
||||||
|
"fiddle": {
|
||||||
|
"tuning": "cross",
|
||||||
|
"learned_from": "Alison Krauss",
|
||||||
|
"date_learned": "2024-06-15",
|
||||||
|
"callable": false,
|
||||||
|
"review": true,
|
||||||
|
"to_learn": false,
|
||||||
|
"difficulty": null
|
||||||
|
},
|
||||||
|
"notes": [
|
||||||
|
{ "note": "tricky B part — watch the timing" }
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"link": "https://www.youtube.com/watch?v=example",
|
||||||
|
"site": "YouTube",
|
||||||
|
"musicians": [
|
||||||
|
{ "name": "Alison Krauss" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
ain/tunes/delete.ain
Normal file
8
ain/tunes/delete.ain
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Delete a tune (cascades to all sub-records)
|
||||||
|
# ain ain/base.ain ain/tunes/delete.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
DELETE
|
||||||
8
ain/tunes/get.ain
Normal file
8
ain/tunes/get.ain
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# Get a single tune by ID
|
||||||
|
# ain ain/base.ain ain/tunes/get.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
GET
|
||||||
21
ain/tunes/list-filtered.ain
Normal file
21
ain/tunes/list-filtered.ain
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Filter tunes — all params are optional, combine as needed
|
||||||
|
# ain ain/base.ain ain/tunes/list-filtered.ain
|
||||||
|
#
|
||||||
|
# Tweak the [Query] section before running.
|
||||||
|
# instrument must be set for tuning/callable/review/to_learn/difficulty to apply.
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/
|
||||||
|
|
||||||
|
[Query]
|
||||||
|
instrument=fiddle
|
||||||
|
key=A
|
||||||
|
tuning=cross
|
||||||
|
callable=true
|
||||||
|
review=false
|
||||||
|
to_learn=false
|
||||||
|
difficulty=easy
|
||||||
|
search=cluck
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
GET
|
||||||
8
ain/tunes/list.ain
Normal file
8
ain/tunes/list.ain
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# List all tunes (no filters)
|
||||||
|
# ain ain/base.ain ain/tunes/list.ain
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
GET
|
||||||
15
ain/tunes/update.ain
Normal file
15
ain/tunes/update.ain
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Update core tune fields (name, key, version)
|
||||||
|
# ain ain/base.ain ain/tunes/update.ain --vars ID=1
|
||||||
|
|
||||||
|
[Host]
|
||||||
|
/tunes/${ID}
|
||||||
|
|
||||||
|
[Method]
|
||||||
|
PATCH
|
||||||
|
|
||||||
|
[Body]
|
||||||
|
{
|
||||||
|
"name": "Cluck Old Hen",
|
||||||
|
"key": "D",
|
||||||
|
"version": "A part only"
|
||||||
|
}
|
||||||
149
alembic.ini
Normal file
149
alembic.ini
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/migrations
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||||
|
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the tzdata library which can be installed by adding
|
||||||
|
# `alembic[tz]` to the pip requirements.
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
53
app/__init__.py
Normal file
53
app/__init__.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""Flask application factory."""
|
||||||
|
import os
|
||||||
|
from flask import Flask
|
||||||
|
from flask_cors import CORS
|
||||||
|
from app.extensions import db
|
||||||
|
from app.routes_tunes import tunes_bp
|
||||||
|
from app.routes_instruments import instruments_bp
|
||||||
|
from app.routes_sub import sub_bp
|
||||||
|
from app.routes_sources import sources_bp
|
||||||
|
from app.routes_musicians import musicians_bp
|
||||||
|
from app.routes_tunings import tunings_bp
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
|
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["DATABASE_URL"]
|
||||||
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
|
|
||||||
|
# --- CORS ---
|
||||||
|
# CORS_ORIGINS in .env is a comma-separated list of allowed origins.
|
||||||
|
# Defaults to * for local dev. Lock this down in production to your
|
||||||
|
# S3 bucket / CloudFront domain, e.g.:
|
||||||
|
# CORS_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
||||||
|
raw = os.environ.get("CORS_ORIGINS", "*")
|
||||||
|
origins = [o.strip() for o in raw.split(",")] if raw != "*" else "*"
|
||||||
|
CORS(app, origins=origins, supports_credentials=False)
|
||||||
|
|
||||||
|
# --- Extensions ---
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
# --- Blueprints ---
|
||||||
|
app.register_blueprint(tunes_bp)
|
||||||
|
app.register_blueprint(instruments_bp)
|
||||||
|
app.register_blueprint(sub_bp)
|
||||||
|
app.register_blueprint(sources_bp)
|
||||||
|
app.register_blueprint(musicians_bp)
|
||||||
|
app.register_blueprint(tunings_bp)
|
||||||
|
|
||||||
|
# --- Error handlers ---
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
@app.errorhandler(IntegrityError)
|
||||||
|
def handle_integrity_error(e):
|
||||||
|
db.session.rollback()
|
||||||
|
return {"error": "Conflict — duplicate or constraint violation"}, 409
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
return app
|
||||||
17
app/auth.py
Normal file
17
app/auth.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""Simple API-key authentication decorator."""
|
||||||
|
import os
|
||||||
|
from functools import wraps
|
||||||
|
from flask import request, jsonify
|
||||||
|
|
||||||
|
|
||||||
|
def require_api_key(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
key = request.headers.get("X-API-Key") or request.args.get("api_key")
|
||||||
|
expected = os.environ.get("API_KEY", "")
|
||||||
|
if not expected:
|
||||||
|
return jsonify({"error": "API key not configured on server"}), 500
|
||||||
|
if key != expected:
|
||||||
|
return jsonify({"error": "Unauthorized"}), 401
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
3
app/extensions.py
Normal file
3
app/extensions.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
175
app/models.py
Normal file
175
app/models.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
from sqlalchemy import Boolean, Column, Date, ForeignKey, Integer, String, Table, UniqueConstraint
|
||||||
|
from app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
# ── Many-to-many: references ↔ musicians ─────────────────────
|
||||||
|
reference_musicians = Table(
|
||||||
|
"reference_musicians",
|
||||||
|
db.metadata,
|
||||||
|
Column("reference_id", Integer, ForeignKey("references.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("musician_id", Integer, ForeignKey("musicians.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Musician(db.Model):
|
||||||
|
__tablename__ = "musicians"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
|
||||||
|
references = db.relationship("Reference", secondary=reference_musicians, back_populates="musicians")
|
||||||
|
instrument_entries = db.relationship("TuneByInstrument", back_populates="learned_from_musician")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"id": self.id, "name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class Source(db.Model):
|
||||||
|
__tablename__ = "sources"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
|
||||||
|
tunes = db.relationship("Tune", back_populates="source")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"id": self.id, "name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class Instrument(db.Model):
|
||||||
|
__tablename__ = "instruments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String, nullable=False, unique=True)
|
||||||
|
|
||||||
|
entries = db.relationship("TuneByInstrument", back_populates="instrument")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"id": self.id, "name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class Tuning(db.Model):
|
||||||
|
__tablename__ = "tunings"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String, nullable=False, unique=True)
|
||||||
|
|
||||||
|
entries = db.relationship("TuneByInstrument", back_populates="tuning")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"id": self.id, "name": self.name}
|
||||||
|
|
||||||
|
|
||||||
|
class Tune(db.Model):
|
||||||
|
__tablename__ = "tunes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name = Column(String, nullable=True)
|
||||||
|
key = Column(String, nullable=True)
|
||||||
|
modal = Column(Boolean, nullable=True)
|
||||||
|
source_id = Column(Integer, ForeignKey("sources.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
|
||||||
|
source = db.relationship("Source", back_populates="tunes")
|
||||||
|
instruments = db.relationship("TuneByInstrument", back_populates="tune", cascade="all, delete-orphan")
|
||||||
|
notes = db.relationship("TuneNote", back_populates="tune", cascade="all, delete-orphan")
|
||||||
|
references = db.relationship("Reference", back_populates="tune", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"key": self.key,
|
||||||
|
"modal": self.modal,
|
||||||
|
"source_id": self.source_id,
|
||||||
|
"source": self.source.to_dict() if self.source else None,
|
||||||
|
"instruments": [i.to_dict() for i in self.instruments],
|
||||||
|
"notes": [n.to_dict() for n in self.notes],
|
||||||
|
"references": [r.to_dict() for r in self.references],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TuneByInstrument(db.Model):
|
||||||
|
__tablename__ = "tune_by_instrument"
|
||||||
|
__table_args__ = (UniqueConstraint("tune_id", "instrument_id", name="uq_tune_instrument"),)
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tune_id = Column(Integer, ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
instrument_id = Column(Integer, ForeignKey("instruments.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
learned_from_id = Column(Integer, ForeignKey("musicians.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
tuning_id = Column(Integer, ForeignKey("tunings.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
date_learned = Column(Date, nullable=True)
|
||||||
|
callable = Column(Boolean, nullable=True)
|
||||||
|
review = Column(Boolean, nullable=True)
|
||||||
|
to_learn = Column(Boolean, nullable=True, default=False)
|
||||||
|
difficulty = Column(String, nullable=True)
|
||||||
|
|
||||||
|
tune = db.relationship("Tune", back_populates="instruments")
|
||||||
|
instrument = db.relationship("Instrument", back_populates="entries")
|
||||||
|
tuning = db.relationship("Tuning", back_populates="entries")
|
||||||
|
learned_from_musician = db.relationship("Musician", back_populates="instrument_entries")
|
||||||
|
notes = db.relationship("TuneByInstrumentNote", back_populates="entry", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"tune_id": self.tune_id,
|
||||||
|
"instrument_id": self.instrument_id,
|
||||||
|
"instrument_name": self.instrument.name if self.instrument else None,
|
||||||
|
"learned_from_id": self.learned_from_id,
|
||||||
|
"learned_from": self.learned_from_musician.name if self.learned_from_musician else None,
|
||||||
|
"date_learned": self.date_learned.isoformat() if self.date_learned else None,
|
||||||
|
"tuning_id": self.tuning_id,
|
||||||
|
"tuning": self.tuning.name if self.tuning else None,
|
||||||
|
"callable": self.callable,
|
||||||
|
"review": self.review,
|
||||||
|
"to_learn": self.to_learn,
|
||||||
|
"difficulty": self.difficulty,
|
||||||
|
"notes": [n.to_dict() for n in self.notes],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TuneNote(db.Model):
|
||||||
|
__tablename__ = "tune_notes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tune_id = Column(Integer, ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
note = Column(String, nullable=True)
|
||||||
|
|
||||||
|
tune = db.relationship("Tune", back_populates="notes")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"id": self.id, "tune_id": self.tune_id, "note": self.note}
|
||||||
|
|
||||||
|
|
||||||
|
class TuneByInstrumentNote(db.Model):
|
||||||
|
__tablename__ = "tune_by_instrument_notes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tune_by_instrument_id = Column(Integer, ForeignKey("tune_by_instrument.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
note = Column(String, nullable=True)
|
||||||
|
|
||||||
|
entry = db.relationship("TuneByInstrument", back_populates="notes")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"id": self.id, "tune_by_instrument_id": self.tune_by_instrument_id, "note": self.note}
|
||||||
|
|
||||||
|
|
||||||
|
class Reference(db.Model):
|
||||||
|
__tablename__ = "references"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
tune_id = Column(Integer, ForeignKey("tunes.id", ondelete="CASCADE"), nullable=True)
|
||||||
|
link = Column(String, nullable=True)
|
||||||
|
site = Column(String, nullable=True)
|
||||||
|
|
||||||
|
tune = db.relationship("Tune", back_populates="references")
|
||||||
|
musicians = db.relationship("Musician", secondary=reference_musicians, back_populates="references")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"tune_id": self.tune_id,
|
||||||
|
"link": self.link,
|
||||||
|
"site": self.site,
|
||||||
|
"musicians": [m.to_dict() for m in self.musicians],
|
||||||
|
}
|
||||||
125
app/routes_instruments.py
Normal file
125
app/routes_instruments.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""Routes for tune_by_instrument entries and their notes."""
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Tune, Instrument, TuneByInstrument, TuneByInstrumentNote
|
||||||
|
from app.auth import require_api_key
|
||||||
|
|
||||||
|
instruments_bp = Blueprint("instruments", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Instruments lookup (public)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@instruments_bp.get("/instruments")
|
||||||
|
def list_instruments():
|
||||||
|
return jsonify([i.to_dict() for i in
|
||||||
|
db.session.query(Instrument).order_by(Instrument.name).all()])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List all tune_by_instrument entries for a tune
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@instruments_bp.get("/tunes/<int:tune_id>/instruments")
|
||||||
|
def list_tune_instruments(tune_id):
|
||||||
|
db.get_or_404(Tune, tune_id)
|
||||||
|
entries = db.session.query(TuneByInstrument).filter_by(tune_id=tune_id).all()
|
||||||
|
return jsonify([e.to_dict() for e in entries])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Create a tune_by_instrument entry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@instruments_bp.post("/tunes/<int:tune_id>/instruments")
|
||||||
|
@require_api_key
|
||||||
|
def create_tune_instrument(tune_id):
|
||||||
|
db.get_or_404(Tune, tune_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
|
||||||
|
# Validate instrument exists
|
||||||
|
instrument_id = data.get("instrument_id")
|
||||||
|
db.get_or_404(Instrument, instrument_id)
|
||||||
|
|
||||||
|
entry = TuneByInstrument(
|
||||||
|
tune_id=tune_id,
|
||||||
|
instrument_id=instrument_id,
|
||||||
|
learned_from_id=data.get("learned_from_id"),
|
||||||
|
tuning_id=data.get("tuning_id"),
|
||||||
|
date_learned=data.get("date_learned"),
|
||||||
|
callable=data.get("callable"),
|
||||||
|
review=data.get("review"),
|
||||||
|
to_learn=data.get("to_learn", False),
|
||||||
|
difficulty=data.get("difficulty"),
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(entry.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Update a tune_by_instrument entry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@instruments_bp.patch("/tunes/<int:tune_id>/instruments/<int:entry_id>")
|
||||||
|
@require_api_key
|
||||||
|
def update_tune_instrument(tune_id, entry_id):
|
||||||
|
entry = db.get_or_404(TuneByInstrument, entry_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
fields = ("instrument_id", "learned_from_id", "tuning_id", "date_learned",
|
||||||
|
"callable", "review", "to_learn", "difficulty")
|
||||||
|
for field in fields:
|
||||||
|
if field in data:
|
||||||
|
setattr(entry, field, data[field])
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(entry.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Delete a tune_by_instrument entry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@instruments_bp.delete("/tunes/<int:tune_id>/instruments/<int:entry_id>")
|
||||||
|
@require_api_key
|
||||||
|
def delete_tune_instrument(tune_id, entry_id):
|
||||||
|
entry = db.get_or_404(TuneByInstrument, entry_id)
|
||||||
|
db.session.delete(entry)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"deleted": entry_id})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Notes on a tune_by_instrument entry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@instruments_bp.get("/tunes/<int:tune_id>/instruments/<int:entry_id>/notes")
|
||||||
|
def list_instrument_notes(tune_id, entry_id):
|
||||||
|
db.get_or_404(TuneByInstrument, entry_id)
|
||||||
|
notes = db.session.query(TuneByInstrumentNote).filter_by(tune_by_instrument_id=entry_id).all()
|
||||||
|
return jsonify([n.to_dict() for n in notes])
|
||||||
|
|
||||||
|
|
||||||
|
@instruments_bp.post("/tunes/<int:tune_id>/instruments/<int:entry_id>/notes")
|
||||||
|
@require_api_key
|
||||||
|
def add_instrument_note(tune_id, entry_id):
|
||||||
|
db.get_or_404(TuneByInstrument, entry_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
note = TuneByInstrumentNote(tune_by_instrument_id=entry_id, note=data.get("note"))
|
||||||
|
db.session.add(note)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(note.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@instruments_bp.patch("/tunes/<int:tune_id>/instruments/<int:entry_id>/notes/<int:note_id>")
|
||||||
|
@require_api_key
|
||||||
|
def update_instrument_note(tune_id, entry_id, note_id):
|
||||||
|
note = db.get_or_404(TuneByInstrumentNote, note_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
if "note" in data:
|
||||||
|
note.note = data["note"]
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(note.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@instruments_bp.delete("/tunes/<int:tune_id>/instruments/<int:entry_id>/notes/<int:note_id>")
|
||||||
|
@require_api_key
|
||||||
|
def delete_instrument_note(tune_id, entry_id, note_id):
|
||||||
|
note = db.get_or_404(TuneByInstrumentNote, note_id)
|
||||||
|
db.session.delete(note)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"deleted": note_id})
|
||||||
43
app/routes_musicians.py
Normal file
43
app/routes_musicians.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Routes for the standalone musicians table."""
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Musician
|
||||||
|
from app.auth import require_api_key
|
||||||
|
|
||||||
|
musicians_bp = Blueprint("musicians", __name__, url_prefix="/musicians")
|
||||||
|
|
||||||
|
|
||||||
|
@musicians_bp.get("/")
|
||||||
|
def list_musicians():
|
||||||
|
musicians = db.session.query(Musician).order_by(Musician.name).all()
|
||||||
|
return jsonify([m.to_dict() for m in musicians])
|
||||||
|
|
||||||
|
|
||||||
|
@musicians_bp.post("/")
|
||||||
|
@require_api_key
|
||||||
|
def create_musician():
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
musician = Musician(name=data["name"])
|
||||||
|
db.session.add(musician)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(musician.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@musicians_bp.patch("/<int:musician_id>")
|
||||||
|
@require_api_key
|
||||||
|
def update_musician(musician_id):
|
||||||
|
musician = db.get_or_404(Musician, musician_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
if "name" in data:
|
||||||
|
musician.name = data["name"]
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(musician.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@musicians_bp.delete("/<int:musician_id>")
|
||||||
|
@require_api_key
|
||||||
|
def delete_musician(musician_id):
|
||||||
|
musician = db.get_or_404(Musician, musician_id)
|
||||||
|
db.session.delete(musician)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"deleted": musician_id})
|
||||||
43
app/routes_sources.py
Normal file
43
app/routes_sources.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Routes for sources (musicians/artists whose version a tune is based on)."""
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Source
|
||||||
|
from app.auth import require_api_key
|
||||||
|
|
||||||
|
sources_bp = Blueprint("sources", __name__, url_prefix="/sources")
|
||||||
|
|
||||||
|
|
||||||
|
@sources_bp.get("/")
|
||||||
|
def list_sources():
|
||||||
|
sources = db.session.query(Source).order_by(Source.name).all()
|
||||||
|
return jsonify([s.to_dict() for s in sources])
|
||||||
|
|
||||||
|
|
||||||
|
@sources_bp.post("/")
|
||||||
|
@require_api_key
|
||||||
|
def create_source():
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
source = Source(name=data["name"])
|
||||||
|
db.session.add(source)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(source.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@sources_bp.patch("/<int:source_id>")
|
||||||
|
@require_api_key
|
||||||
|
def update_source(source_id):
|
||||||
|
source = db.get_or_404(Source, source_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
if "name" in data:
|
||||||
|
source.name = data["name"]
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(source.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@sources_bp.delete("/<int:source_id>")
|
||||||
|
@require_api_key
|
||||||
|
def delete_source(source_id):
|
||||||
|
source = db.get_or_404(Source, source_id)
|
||||||
|
db.session.delete(source)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"deleted": source_id})
|
||||||
124
app/routes_sub.py
Normal file
124
app/routes_sub.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Routes for notes and references sub-resources."""
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Tune, TuneNote, Reference, Musician
|
||||||
|
from app.auth import require_api_key
|
||||||
|
|
||||||
|
sub_bp = Blueprint("sub", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Notes
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
@sub_bp.get("/tunes/<int:tune_id>/notes")
|
||||||
|
def list_notes(tune_id):
|
||||||
|
db.get_or_404(Tune, tune_id)
|
||||||
|
notes = db.session.query(TuneNote).filter_by(tune_id=tune_id).all()
|
||||||
|
return jsonify([n.to_dict() for n in notes])
|
||||||
|
|
||||||
|
|
||||||
|
@sub_bp.post("/tunes/<int:tune_id>/notes")
|
||||||
|
@require_api_key
|
||||||
|
def add_note(tune_id):
|
||||||
|
db.get_or_404(Tune, tune_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
note = TuneNote(tune_id=tune_id, note=data.get("note"))
|
||||||
|
db.session.add(note)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(note.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@sub_bp.patch("/tunes/<int:tune_id>/notes/<int:note_id>")
|
||||||
|
@require_api_key
|
||||||
|
def update_note(tune_id, note_id):
|
||||||
|
note = db.get_or_404(TuneNote, note_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
if "note" in data:
|
||||||
|
note.note = data["note"]
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(note.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@sub_bp.delete("/tunes/<int:tune_id>/notes/<int:note_id>")
|
||||||
|
@require_api_key
|
||||||
|
def delete_note(tune_id, note_id):
|
||||||
|
note = db.get_or_404(TuneNote, note_id)
|
||||||
|
db.session.delete(note)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"deleted": note_id})
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# References
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
@sub_bp.get("/tunes/<int:tune_id>/references")
|
||||||
|
def list_references(tune_id):
|
||||||
|
db.get_or_404(Tune, tune_id)
|
||||||
|
refs = db.session.query(Reference).filter_by(tune_id=tune_id).all()
|
||||||
|
return jsonify([r.to_dict() for r in refs])
|
||||||
|
|
||||||
|
|
||||||
|
@sub_bp.post("/tunes/<int:tune_id>/references")
|
||||||
|
@require_api_key
|
||||||
|
def add_reference(tune_id):
|
||||||
|
db.get_or_404(Tune, tune_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
ref = Reference(tune_id=tune_id, link=data.get("link"), site=data.get("site"))
|
||||||
|
db.session.add(ref)
|
||||||
|
db.session.flush()
|
||||||
|
for m in data.get("musicians", []):
|
||||||
|
musician = db.session.get(Musician, m["id"])
|
||||||
|
if musician:
|
||||||
|
ref.musicians.append(musician)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(ref.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@sub_bp.patch("/tunes/<int:tune_id>/references/<int:ref_id>")
|
||||||
|
@require_api_key
|
||||||
|
def update_reference(tune_id, ref_id):
|
||||||
|
ref = db.get_or_404(Reference, ref_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
for field in ("link", "site"):
|
||||||
|
if field in data:
|
||||||
|
setattr(ref, field, data[field])
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(ref.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@sub_bp.delete("/tunes/<int:tune_id>/references/<int:ref_id>")
|
||||||
|
@require_api_key
|
||||||
|
def delete_reference(tune_id, ref_id):
|
||||||
|
ref = db.get_or_404(Reference, ref_id)
|
||||||
|
db.session.delete(ref)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"deleted": ref_id})
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Musicians on a reference (add/remove via join table)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
@sub_bp.post("/tunes/<int:tune_id>/references/<int:ref_id>/musicians")
|
||||||
|
@require_api_key
|
||||||
|
def add_musician_to_reference(tune_id, ref_id):
|
||||||
|
ref = db.get_or_404(Reference, ref_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
musician = db.get_or_404(Musician, data["musician_id"])
|
||||||
|
if musician not in ref.musicians:
|
||||||
|
ref.musicians.append(musician)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(ref.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@sub_bp.delete("/tunes/<int:tune_id>/references/<int:ref_id>/musicians/<int:musician_id>")
|
||||||
|
@require_api_key
|
||||||
|
def remove_musician_from_reference(tune_id, ref_id, musician_id):
|
||||||
|
ref = db.get_or_404(Reference, ref_id)
|
||||||
|
musician = db.get_or_404(Musician, musician_id)
|
||||||
|
if musician in ref.musicians:
|
||||||
|
ref.musicians.remove(musician)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(ref.to_dict())
|
||||||
155
app/routes_tunes.py
Normal file
155
app/routes_tunes.py
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
"""Routes for the core tunes table."""
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Tune, TuneByInstrument, TuneNote, Reference, Musician
|
||||||
|
from app.auth import require_api_key
|
||||||
|
|
||||||
|
tunes_bp = Blueprint("tunes", __name__, url_prefix="/tunes")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List / filter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@tunes_bp.get("/")
|
||||||
|
def list_tunes():
|
||||||
|
"""
|
||||||
|
Query params (all optional, combinable):
|
||||||
|
instrument_id=1
|
||||||
|
key=D
|
||||||
|
tuning_id=1
|
||||||
|
callable=true|false
|
||||||
|
review=true|false
|
||||||
|
to_learn=true|false
|
||||||
|
difficulty=easy|hard
|
||||||
|
modal=true|false
|
||||||
|
source_id=1
|
||||||
|
search=<substring match on name>
|
||||||
|
"""
|
||||||
|
q = db.session.query(Tune)
|
||||||
|
|
||||||
|
search = request.args.get("search")
|
||||||
|
if search:
|
||||||
|
q = q.filter(Tune.name.ilike(f"%{search}%"))
|
||||||
|
|
||||||
|
key = request.args.get("key")
|
||||||
|
if key:
|
||||||
|
q = q.filter(Tune.key.ilike(key))
|
||||||
|
|
||||||
|
modal = request.args.get("modal")
|
||||||
|
if modal is not None:
|
||||||
|
q = q.filter(Tune.modal == (modal.lower() in ("true", "1", "yes")))
|
||||||
|
|
||||||
|
source_id = request.args.get("source_id")
|
||||||
|
if source_id:
|
||||||
|
q = q.filter(Tune.source_id == int(source_id))
|
||||||
|
|
||||||
|
instrument_id = request.args.get("instrument_id")
|
||||||
|
tuning = request.args.get("tuning")
|
||||||
|
callable_ = request.args.get("callable")
|
||||||
|
review = request.args.get("review")
|
||||||
|
to_learn = request.args.get("to_learn")
|
||||||
|
difficulty = request.args.get("difficulty")
|
||||||
|
|
||||||
|
def _bool(val):
|
||||||
|
return val.lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
|
if any([instrument_id, tuning, callable_, review, to_learn, difficulty]):
|
||||||
|
q = q.join(Tune.instruments)
|
||||||
|
if instrument_id:
|
||||||
|
q = q.filter(TuneByInstrument.instrument_id == int(instrument_id))
|
||||||
|
if tuning:
|
||||||
|
q = q.filter(TuneByInstrument.tuning_id == int(tuning))
|
||||||
|
if callable_ is not None:
|
||||||
|
q = q.filter(TuneByInstrument.callable == _bool(callable_))
|
||||||
|
if review is not None:
|
||||||
|
q = q.filter(TuneByInstrument.review == _bool(review))
|
||||||
|
if to_learn is not None:
|
||||||
|
q = q.filter(TuneByInstrument.to_learn == _bool(to_learn))
|
||||||
|
if difficulty:
|
||||||
|
q = q.filter(TuneByInstrument.difficulty.ilike(difficulty))
|
||||||
|
|
||||||
|
tunes = q.order_by(Tune.name).all()
|
||||||
|
return jsonify([t.to_dict() for t in tunes])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Get single tune
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@tunes_bp.get("/<int:tune_id>")
|
||||||
|
def get_tune(tune_id):
|
||||||
|
tune = db.get_or_404(Tune, tune_id)
|
||||||
|
return jsonify(tune.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Create tune (with optional nested instruments / notes / references)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@tunes_bp.post("/")
|
||||||
|
@require_api_key
|
||||||
|
def create_tune():
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
|
||||||
|
tune = Tune(
|
||||||
|
name=data.get("name"),
|
||||||
|
key=data.get("key"),
|
||||||
|
modal=data.get("modal"),
|
||||||
|
source_id=data.get("source_id"),
|
||||||
|
)
|
||||||
|
db.session.add(tune)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
for inst_data in data.get("instruments", []):
|
||||||
|
entry = TuneByInstrument(
|
||||||
|
tune_id=tune.id,
|
||||||
|
instrument_id=inst_data["instrument_id"],
|
||||||
|
learned_from_id=inst_data.get("learned_from_id"),
|
||||||
|
tuning_id=inst_data.get("tuning_id"),
|
||||||
|
date_learned=inst_data.get("date_learned"),
|
||||||
|
callable=inst_data.get("callable"),
|
||||||
|
review=inst_data.get("review"),
|
||||||
|
to_learn=inst_data.get("to_learn", False),
|
||||||
|
difficulty=inst_data.get("difficulty"),
|
||||||
|
)
|
||||||
|
db.session.add(entry)
|
||||||
|
|
||||||
|
for note_data in data.get("notes", []):
|
||||||
|
db.session.add(TuneNote(tune_id=tune.id, note=note_data.get("note")))
|
||||||
|
|
||||||
|
for ref_data in data.get("references", []):
|
||||||
|
ref = Reference(tune_id=tune.id, link=ref_data.get("link"), site=ref_data.get("site"))
|
||||||
|
db.session.add(ref)
|
||||||
|
db.session.flush()
|
||||||
|
for m in ref_data.get("musicians", []):
|
||||||
|
musician = db.session.get(Musician, m["id"])
|
||||||
|
if musician:
|
||||||
|
ref.musicians.append(musician)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(tune.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Update tune core fields
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@tunes_bp.patch("/<int:tune_id>")
|
||||||
|
@require_api_key
|
||||||
|
def update_tune(tune_id):
|
||||||
|
tune = db.get_or_404(Tune, tune_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
for field in ("name", "key", "modal", "source_id"):
|
||||||
|
if field in data:
|
||||||
|
setattr(tune, field, data[field])
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(tune.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Delete tune
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@tunes_bp.delete("/<int:tune_id>")
|
||||||
|
@require_api_key
|
||||||
|
def delete_tune(tune_id):
|
||||||
|
tune = db.get_or_404(Tune, tune_id)
|
||||||
|
db.session.delete(tune)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"deleted": tune_id})
|
||||||
43
app/routes_tunings.py
Normal file
43
app/routes_tunings.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"""Routes for the tunings lookup table."""
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models import Tuning
|
||||||
|
from app.auth import require_api_key
|
||||||
|
|
||||||
|
tunings_bp = Blueprint("tunings", __name__, url_prefix="/tunings")
|
||||||
|
|
||||||
|
|
||||||
|
@tunings_bp.get("/")
|
||||||
|
def list_tunings():
|
||||||
|
return jsonify([t.to_dict() for t in
|
||||||
|
db.session.query(Tuning).order_by(Tuning.name).all()])
|
||||||
|
|
||||||
|
|
||||||
|
@tunings_bp.post("/")
|
||||||
|
@require_api_key
|
||||||
|
def create_tuning():
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
tuning = Tuning(name=data["name"])
|
||||||
|
db.session.add(tuning)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(tuning.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@tunings_bp.patch("/<int:tuning_id>")
|
||||||
|
@require_api_key
|
||||||
|
def update_tuning(tuning_id):
|
||||||
|
tuning = db.get_or_404(Tuning, tuning_id)
|
||||||
|
data = request.get_json(force=True)
|
||||||
|
if "name" in data:
|
||||||
|
tuning.name = data["name"]
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(tuning.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@tunings_bp.delete("/<int:tuning_id>")
|
||||||
|
@require_api_key
|
||||||
|
def delete_tuning(tuning_id):
|
||||||
|
tuning = db.get_or_404(Tuning, tuning_id)
|
||||||
|
db.session.delete(tuning)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"deleted": tuning_id})
|
||||||
20
docker/Dockerfile
Normal file
20
docker/Dockerfile
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install uv
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq-dev gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy dependency files and install — leverages Docker layer cache
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --no-dev --frozen
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["uv", "run", "flask", "--app", "wsgi:app", "run", "--host=0.0.0.0", "--port=5000"]
|
||||||
29
docker/docker-compose.yml
Normal file
29
docker/docker-compose.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: repertory
|
||||||
|
POSTGRES_PASSWORD: repertory
|
||||||
|
POSTGRES_DB: tunes
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-5000}:5000"
|
||||||
|
env_file:
|
||||||
|
- ../.env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://repertory:repertory@db:5432/tunes
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
33
env.example
Normal file
33
env.example
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Copy this file to .env and fill in your values.
|
||||||
|
|
||||||
|
# --- Database ---
|
||||||
|
# Local dev (matches docker/docker-compose.yml defaults):
|
||||||
|
DATABASE_URL=postgresql://repertory:repertory@localhost:5432/tunes
|
||||||
|
|
||||||
|
# Production example:
|
||||||
|
# DATABASE_URL=postgresql://user:password@your-db-host:5432/tunes
|
||||||
|
|
||||||
|
# --- Ports ---
|
||||||
|
# If you change DB_PORT, update the port in DATABASE_URL to match.
|
||||||
|
# If you change API_PORT, update the port in REPERTORY_HOST to match.
|
||||||
|
API_PORT=5000
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# --- Flask ---
|
||||||
|
FLASK_ENV=development
|
||||||
|
FLASK_DEBUG=1
|
||||||
|
|
||||||
|
# --- CORS ---
|
||||||
|
# Comma-separated list of allowed origins, or * for local dev.
|
||||||
|
# In production set this to your S3/CloudFront domain:
|
||||||
|
# CORS_ORIGINS=https://yourdomain.com
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
|
||||||
|
# --- API Key ---
|
||||||
|
# For local dev set this directly.
|
||||||
|
# In production this is retrieved at startup via SOPS + age (see README).
|
||||||
|
API_KEY=dev-secret-key-change-me
|
||||||
|
|
||||||
|
# --- ain (terminal HTTP client) ---
|
||||||
|
# Used by the ain templates in ain/ — set to wherever the API is running.
|
||||||
|
REPERTORY_HOST=http://localhost:5000
|
||||||
1
migrations/README
Normal file
1
migrations/README
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Generic single-database configuration.
|
||||||
58
migrations/env.py
Normal file
58
migrations/env.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Alembic environment — wired to our SQLAlchemy models and DATABASE_URL."""
|
||||||
|
import os
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Load .env so DATABASE_URL is available when running alembic locally.
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Alembic Config object
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Set up loggers from alembic.ini
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Override sqlalchemy.url from environment (takes precedence over alembic.ini).
|
||||||
|
db_url = os.environ.get("DATABASE_URL")
|
||||||
|
if db_url:
|
||||||
|
config.set_main_option("sqlalchemy.url", db_url)
|
||||||
|
|
||||||
|
# Import models so Alembic autogenerate can see the metadata.
|
||||||
|
from app.extensions import db # noqa: E402
|
||||||
|
import app.models # noqa: E402, F401 — registers all models on db.metadata
|
||||||
|
|
||||||
|
target_metadata = db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
28
migrations/script.py.mako
Normal file
28
migrations/script.py.mako
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
21
migrations/versions/024f72e7bea5_drop_tune_id_from_tunes.py
Normal file
21
migrations/versions/024f72e7bea5_drop_tune_id_from_tunes.py
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"""drop tune_id from tunes
|
||||||
|
|
||||||
|
Revision ID: 024f72e7bea5
|
||||||
|
Revises: 2bb981f6ee16
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "024f72e7bea5"
|
||||||
|
down_revision = "2bb981f6ee16"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("tunes", "tune_id")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
import sqlalchemy as sa
|
||||||
|
op.add_column("tunes", sa.Column("tune_id", sa.Integer(), nullable=True))
|
||||||
82
migrations/versions/2bb981f6ee16_initial_schema.py
Normal file
82
migrations/versions/2bb981f6ee16_initial_schema.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: 2bb981f6ee16
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "2bb981f6ee16"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tunes",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=True),
|
||||||
|
sa.Column("version", sa.String(), nullable=True),
|
||||||
|
sa.Column("key", sa.String(), nullable=True),
|
||||||
|
sa.Column("tune_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"banjo",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tune_id", sa.Integer(), sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("learned_from", sa.String(), nullable=True),
|
||||||
|
sa.Column("date_learned", sa.Date(), nullable=True),
|
||||||
|
sa.Column("tuning", sa.String(), nullable=True),
|
||||||
|
sa.Column("callable", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("review", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("to_learn", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("difficulty", sa.String(), nullable=True),
|
||||||
|
sa.UniqueConstraint("tune_id", name="banjo_tune_id_unique"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"fiddle",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tune_id", sa.Integer(), sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("learned_from", sa.String(), nullable=True),
|
||||||
|
sa.Column("date_learned", sa.Date(), nullable=True),
|
||||||
|
sa.Column("tuning", sa.String(), nullable=True),
|
||||||
|
sa.Column("callable", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("review", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("to_learn", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("difficulty", sa.String(), nullable=True),
|
||||||
|
sa.UniqueConstraint("tune_id", name="fiddle_tune_id_unique"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"notes",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tune_id", sa.Integer(), sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("note", sa.String(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"references",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tune_id", sa.Integer(), sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("link", sa.String(), nullable=True),
|
||||||
|
sa.Column("site", sa.String(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"musicians",
|
||||||
|
sa.Column("name", sa.String(), primary_key=True, nullable=False),
|
||||||
|
sa.Column("reference_id", sa.Integer(), sa.ForeignKey("references.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("musicians")
|
||||||
|
op.drop_table("references")
|
||||||
|
op.drop_table("notes")
|
||||||
|
op.drop_table("fiddle")
|
||||||
|
op.drop_table("banjo")
|
||||||
|
op.drop_table("tunes")
|
||||||
65
migrations/versions/2bd4f3ff0ccc_decouple_musicians.py
Normal file
65
migrations/versions/2bd4f3ff0ccc_decouple_musicians.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""decouple musicians: standalone table, join table for references, fk for learned_from
|
||||||
|
|
||||||
|
Revision ID: 2bd4f3ff0ccc
|
||||||
|
Revises: ea4bb85f9908
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "2bd4f3ff0ccc"
|
||||||
|
down_revision = "ea4bb85f9908"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Drop the old musicians table (string PK, tied to references)
|
||||||
|
op.drop_table("musicians")
|
||||||
|
|
||||||
|
# 2. Create standalone musicians table with integer PK
|
||||||
|
op.create_table(
|
||||||
|
"musicians",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Create reference_musicians join table (many-to-many)
|
||||||
|
op.create_table(
|
||||||
|
"reference_musicians",
|
||||||
|
sa.Column("reference_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("references.id", ondelete="CASCADE"),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column("musician_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("musicians.id", ondelete="CASCADE"),
|
||||||
|
nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("reference_id", "musician_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Add learned_from_id FK to banjo and fiddle (keep old learned_from text
|
||||||
|
# column for the downgrade path, then drop it)
|
||||||
|
op.add_column("banjo", sa.Column("learned_from_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("musicians.id", ondelete="SET NULL"),
|
||||||
|
nullable=True))
|
||||||
|
op.add_column("fiddle", sa.Column("learned_from_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("musicians.id", ondelete="SET NULL"),
|
||||||
|
nullable=True))
|
||||||
|
op.drop_column("banjo", "learned_from")
|
||||||
|
op.drop_column("fiddle", "learned_from")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column("banjo", sa.Column("learned_from", sa.String(), nullable=True))
|
||||||
|
op.add_column("fiddle", sa.Column("learned_from", sa.String(), nullable=True))
|
||||||
|
op.drop_column("banjo", "learned_from_id")
|
||||||
|
op.drop_column("fiddle", "learned_from_id")
|
||||||
|
op.drop_table("reference_musicians")
|
||||||
|
op.drop_table("musicians")
|
||||||
|
# Recreate original musicians table
|
||||||
|
op.create_table(
|
||||||
|
"musicians",
|
||||||
|
sa.Column("name", sa.String(), primary_key=True, nullable=False),
|
||||||
|
sa.Column("reference_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("references.id", ondelete="CASCADE"),
|
||||||
|
nullable=True),
|
||||||
|
)
|
||||||
29
migrations/versions/57326830536b_instrument_tunings.py
Normal file
29
migrations/versions/57326830536b_instrument_tunings.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""add instrument_tunings join table
|
||||||
|
|
||||||
|
Revision ID: 57326830536b
|
||||||
|
Revises: abdeb80d003e
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "57326830536b"
|
||||||
|
down_revision = "abdeb80d003e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"instrument_tunings",
|
||||||
|
sa.Column("instrument_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("instruments.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True, nullable=False),
|
||||||
|
sa.Column("tuning_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("tunings.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True, nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("instrument_tunings")
|
||||||
90
migrations/versions/84db90f5499e_tune_by_instrument.py
Normal file
90
migrations/versions/84db90f5499e_tune_by_instrument.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""replace banjo/fiddle with instruments + tune_by_instrument tables
|
||||||
|
|
||||||
|
Revision ID: 84db90f5499e
|
||||||
|
Revises: 2bd4f3ff0ccc
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "84db90f5499e"
|
||||||
|
down_revision = "2bd4f3ff0ccc"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Drop old per-instrument tables (and their notes)
|
||||||
|
op.drop_table("banjo")
|
||||||
|
op.drop_table("fiddle")
|
||||||
|
|
||||||
|
# Rename notes → tune_notes
|
||||||
|
op.rename_table("notes", "tune_notes")
|
||||||
|
|
||||||
|
# 2. Instruments lookup table
|
||||||
|
op.create_table(
|
||||||
|
"instruments",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=False, unique=True),
|
||||||
|
)
|
||||||
|
# Seed banjo and fiddle
|
||||||
|
op.execute("INSERT INTO instruments (name) VALUES ('banjo'), ('fiddle')")
|
||||||
|
|
||||||
|
# 3. Generic tune_by_instrument table
|
||||||
|
op.create_table(
|
||||||
|
"tune_by_instrument",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tune_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("instrument_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("instruments.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("learned_from_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("musicians.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("date_learned", sa.Date(), nullable=True),
|
||||||
|
sa.Column("tuning", sa.String(), nullable=True),
|
||||||
|
sa.Column("callable", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("review", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("to_learn", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("difficulty", sa.String(), nullable=True),
|
||||||
|
sa.UniqueConstraint("tune_id", "instrument_id", name="uq_tune_instrument"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Instrument-specific notes
|
||||||
|
op.create_table(
|
||||||
|
"tune_by_instrument_notes",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tune_by_instrument_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("tune_by_instrument.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("note", sa.String(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("tune_by_instrument_notes")
|
||||||
|
op.drop_table("tune_by_instrument")
|
||||||
|
op.drop_table("instruments")
|
||||||
|
op.rename_table("tune_notes", "notes")
|
||||||
|
op.create_table(
|
||||||
|
"banjo",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tune_id", sa.Integer(), sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False, unique=True),
|
||||||
|
sa.Column("learned_from_id", sa.Integer(), sa.ForeignKey("musicians.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("date_learned", sa.Date(), nullable=True),
|
||||||
|
sa.Column("tuning", sa.String(), nullable=True),
|
||||||
|
sa.Column("callable", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("review", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("to_learn", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("difficulty", sa.String(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"fiddle",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("tune_id", sa.Integer(), sa.ForeignKey("tunes.id", ondelete="CASCADE"), nullable=False, unique=True),
|
||||||
|
sa.Column("learned_from_id", sa.Integer(), sa.ForeignKey("musicians.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("date_learned", sa.Date(), nullable=True),
|
||||||
|
sa.Column("tuning", sa.String(), nullable=True),
|
||||||
|
sa.Column("callable", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("review", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("to_learn", sa.Boolean(), nullable=True),
|
||||||
|
sa.Column("difficulty", sa.String(), nullable=True),
|
||||||
|
)
|
||||||
32
migrations/versions/abdeb80d003e_add_tunings.py
Normal file
32
migrations/versions/abdeb80d003e_add_tunings.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""add tunings table and tuning_id fk on tune_by_instrument
|
||||||
|
|
||||||
|
Revision ID: abdeb80d003e
|
||||||
|
Revises: 84db90f5499e
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "abdeb80d003e"
|
||||||
|
down_revision = "84db90f5499e"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"tunings",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=False, unique=True),
|
||||||
|
)
|
||||||
|
op.drop_column("tune_by_instrument", "tuning")
|
||||||
|
op.add_column("tune_by_instrument",
|
||||||
|
sa.Column("tuning_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("tunings.id", ondelete="SET NULL"),
|
||||||
|
nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tune_by_instrument", "tuning_id")
|
||||||
|
op.add_column("tune_by_instrument", sa.Column("tuning", sa.String(), nullable=True))
|
||||||
|
op.drop_table("tunings")
|
||||||
34
migrations/versions/ea4bb85f9908_add_sources.py
Normal file
34
migrations/versions/ea4bb85f9908_add_sources.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""add sources table, source_id and modal to tunes, drop version
|
||||||
|
|
||||||
|
Revision ID: ea4bb85f9908
|
||||||
|
Revises: 024f72e7bea5
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "ea4bb85f9908"
|
||||||
|
down_revision = "024f72e7bea5"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"sources",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
|
||||||
|
sa.Column("name", sa.String(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_column("tunes", "version")
|
||||||
|
op.add_column("tunes", sa.Column("source_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("sources.id", ondelete="SET NULL"),
|
||||||
|
nullable=True))
|
||||||
|
op.add_column("tunes", sa.Column("modal", sa.Boolean(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("tunes", "modal")
|
||||||
|
op.drop_column("tunes", "source_id")
|
||||||
|
op.add_column("tunes", sa.Column("version", sa.String(), nullable=True))
|
||||||
|
op.drop_table("sources")
|
||||||
200
nixos/module.nix
Normal file
200
nixos/module.nix
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
# NixOS module for repertory-api
|
||||||
|
#
|
||||||
|
# This module fetches the application source directly from a self-hosted Forgejo instance at a pinned
|
||||||
|
# tag/commit, builds a Python environment, runs Alembic migrations on start,
|
||||||
|
# and manages the Flask service under systemd.
|
||||||
|
#
|
||||||
|
# ── Typical usage in configuration.nix ───────────────────────────────────────
|
||||||
|
#
|
||||||
|
# imports = [ (fetchTarball {
|
||||||
|
# url = "https://git.yourdomain.com/yourusername/repertory-api/archive/v1.0.0.tar.gz";
|
||||||
|
# sha256 = "0000000000000000000000000000000000000000000000000000";
|
||||||
|
# } + "/nixos/module.nix") ];
|
||||||
|
#
|
||||||
|
# ── Secrets ──────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The module expects two sops-nix secrets to exist. Add to configuration.nix:
|
||||||
|
#
|
||||||
|
# sops.secrets."repertory-api-key" = {};
|
||||||
|
# sops.secrets."repertory-db-url" = {};
|
||||||
|
#
|
||||||
|
# ── Minimal configuration.nix example ────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# services.repertory-api = {
|
||||||
|
# enable = true;
|
||||||
|
# domain = "git.yourdomain.com";
|
||||||
|
# owner = "yourusername";
|
||||||
|
# version = "v1.0.0";
|
||||||
|
# rev = "abc123..."; # git commit SHA for the tag
|
||||||
|
# sha256 = "sha256-..."; # `nix-prefetch-url --unpack <tarball url>`
|
||||||
|
# };
|
||||||
|
#
|
||||||
|
# ── Updating ─────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# 1. Push a new tag to your Forgejo instance (e.g. v1.1.0).
|
||||||
|
# 2. Get the new sha256:
|
||||||
|
# nix-prefetch-url --unpack \
|
||||||
|
# https://git.yourdomain.com/yourusername/repertory-api/archive/v1.1.0.tar.gz
|
||||||
|
# 3. Update `version`, `rev`, and `sha256` in your configuration.nix.
|
||||||
|
# 4. nixos-rebuild switch
|
||||||
|
# → Nix fetches the new source, rebuilds the Python env, restarts the
|
||||||
|
# service, and runs migrations automatically in preStart.
|
||||||
|
#
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.repertory-api;
|
||||||
|
|
||||||
|
# Fetch the application source from the self-hosted Forgejo instance at the
|
||||||
|
# pinned rev. Nix verifies the sha256 hash, so this is fully reproducible.
|
||||||
|
appSrc = pkgs.fetchFromGitea {
|
||||||
|
domain = cfg.domain;
|
||||||
|
owner = cfg.owner;
|
||||||
|
repo = "repertory-api";
|
||||||
|
rev = cfg.rev;
|
||||||
|
sha256 = cfg.sha256;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Build the Python environment with all runtime dependencies.
|
||||||
|
# This mirrors pyproject.toml [project.dependencies].
|
||||||
|
# uv is a local dev tool — on NixOS, Nix itself plays the role of
|
||||||
|
# dependency resolver and the Python env is built at evaluation time.
|
||||||
|
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
||||||
|
flask
|
||||||
|
flask-sqlalchemy
|
||||||
|
sqlalchemy
|
||||||
|
psycopg2
|
||||||
|
python-dotenv
|
||||||
|
alembic
|
||||||
|
]);
|
||||||
|
|
||||||
|
in {
|
||||||
|
|
||||||
|
options.services.repertory-api = {
|
||||||
|
enable = mkEnableOption "Repertory API";
|
||||||
|
|
||||||
|
domain = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "Hostname of your self-hosted Forgejo instance.";
|
||||||
|
example = "git.yourdomain.com";
|
||||||
|
};
|
||||||
|
|
||||||
|
owner = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "Forgejo username / org that owns the repertory-api repo.";
|
||||||
|
example = "yourusername";
|
||||||
|
};
|
||||||
|
|
||||||
|
version = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "Human-readable version label (used only for documentation/logging).";
|
||||||
|
example = "v1.0.0";
|
||||||
|
};
|
||||||
|
|
||||||
|
rev = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "Git commit SHA for the tag you want to deploy.";
|
||||||
|
example = "abc1234def5678";
|
||||||
|
};
|
||||||
|
|
||||||
|
sha256 = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = ''
|
||||||
|
sha256 hash of the fetched source tarball.
|
||||||
|
Obtain with:
|
||||||
|
nix-prefetch-url --unpack \
|
||||||
|
https://git.yourdomain.com/<owner>/repertory-api/archive/<rev>.tar.gz
|
||||||
|
'';
|
||||||
|
example = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
|
};
|
||||||
|
|
||||||
|
port = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 5000;
|
||||||
|
description = "Port the Flask app listens on.";
|
||||||
|
};
|
||||||
|
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "repertory";
|
||||||
|
description = "Unix user to run the service as.";
|
||||||
|
};
|
||||||
|
|
||||||
|
group = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "repertory";
|
||||||
|
description = "Unix group to run the service as.";
|
||||||
|
};
|
||||||
|
|
||||||
|
apiKeySecret = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "/run/secrets/repertory-api-key";
|
||||||
|
description = "Path to the SOPS-decrypted file containing the raw API key.";
|
||||||
|
};
|
||||||
|
|
||||||
|
dbUrlSecret = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "/run/secrets/repertory-db-url";
|
||||||
|
description = "Path to the SOPS-decrypted file containing the DATABASE_URL string.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
|
||||||
|
users.users.${cfg.user} = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = cfg.group;
|
||||||
|
home = "/var/lib/repertory-api";
|
||||||
|
createHome = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
users.groups.${cfg.group} = {};
|
||||||
|
|
||||||
|
systemd.services.repertory-api = {
|
||||||
|
description = "Repertory API (Flask) — ${cfg.version}";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" "postgresql.service" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
WorkingDirectory = appSrc;
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "5s";
|
||||||
|
|
||||||
|
ExecStart = "${pythonEnv}/bin/python -m flask --app wsgi:app run --host=0.0.0.0 --port=${toString cfg.port}";
|
||||||
|
|
||||||
|
# Secrets are injected via the env file written in preStart.
|
||||||
|
EnvironmentFiles = [ "/run/repertory-api/env" ];
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
# /run/repertory-api is on tmpfs and holds only the mode-600 env file.
|
||||||
|
ReadWritePaths = [ "/run/repertory-api" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
preStart = ''
|
||||||
|
# Write secrets into a tmpfs env file so nothing is on-disk in plaintext.
|
||||||
|
mkdir -p /run/repertory-api
|
||||||
|
chmod 700 /run/repertory-api
|
||||||
|
printf 'API_KEY=%s\n' "$(cat ${cfg.apiKeySecret})" > /run/repertory-api/env
|
||||||
|
printf 'DATABASE_URL=%s\n' "$(cat ${cfg.dbUrlSecret})" >> /run/repertory-api/env
|
||||||
|
chmod 600 /run/repertory-api/env
|
||||||
|
|
||||||
|
# Run any pending Alembic migrations before the server starts.
|
||||||
|
# On a fresh database this creates all tables; on subsequent deploys
|
||||||
|
# it applies only new migration files.
|
||||||
|
export DATABASE_URL="$(cat ${cfg.dbUrlSecret})"
|
||||||
|
cd ${appSrc}
|
||||||
|
${pythonEnv}/bin/python -m alembic upgrade head
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
19
pyproject.toml
Normal file
19
pyproject.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[project]
|
||||||
|
name = "repertory-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"flask>=3.0",
|
||||||
|
"flask-sqlalchemy>=3.1",
|
||||||
|
"flask-cors>=4.0",
|
||||||
|
"sqlalchemy>=2.0",
|
||||||
|
"psycopg2-binary>=2.9",
|
||||||
|
"python-dotenv>=1.0",
|
||||||
|
"alembic>=1.13",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-flask>=1.3",
|
||||||
|
]
|
||||||
53
tests/conftest.py
Normal file
53
tests/conftest.py
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"""
|
||||||
|
Shared pytest fixtures.
|
||||||
|
Tests run against SQLite in-memory — no Docker required.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||||
|
os.environ.setdefault("API_KEY", "test-key")
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from app.extensions import db as _db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def app():
|
||||||
|
application = create_app()
|
||||||
|
application.config.update(
|
||||||
|
TESTING=True,
|
||||||
|
SQLALCHEMY_DATABASE_URI="sqlite:///:memory:",
|
||||||
|
)
|
||||||
|
with application.app_context():
|
||||||
|
_db.create_all()
|
||||||
|
# Seed instruments
|
||||||
|
from app.models import Instrument
|
||||||
|
for name in ("banjo", "fiddle"):
|
||||||
|
if not _db.session.query(Instrument).filter_by(name=name).first():
|
||||||
|
_db.session.add(Instrument(name=name))
|
||||||
|
_db.session.commit()
|
||||||
|
yield application
|
||||||
|
_db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clean_db(app):
|
||||||
|
with app.app_context():
|
||||||
|
yield
|
||||||
|
_db.session.rollback()
|
||||||
|
from app.models import (TuneByInstrumentNote, TuneByInstrument,
|
||||||
|
Reference, TuneNote, Tune, Source, Musician, Tuning)
|
||||||
|
for model in (TuneByInstrumentNote, Reference, TuneNote,
|
||||||
|
TuneByInstrument, Tune, Source, Musician, Tuning):
|
||||||
|
_db.session.query(model).delete()
|
||||||
|
_db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
AUTH = {"X-API-Key": "test-key"}
|
||||||
|
BAD_AUTH = {"X-API-Key": "wrong-key"}
|
||||||
130
tests/test_instruments.py
Normal file
130
tests/test_instruments.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""Tests for tune_by_instrument endpoints."""
|
||||||
|
from tests.conftest import AUTH
|
||||||
|
|
||||||
|
|
||||||
|
def make_tune(client):
|
||||||
|
r = client.post("/tunes/", json={"name": "Test Tune", "key": "D"}, headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
return r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def make_musician(client, name="Earl Scruggs"):
|
||||||
|
r = client.post("/musicians/", json={"name": name}, headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
return r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_instrument_id(client, name):
|
||||||
|
return next(i["id"] for i in client.get("/instruments").get_json() if i["name"] == name)
|
||||||
|
|
||||||
|
|
||||||
|
def make_tuning(client, name="double C"):
|
||||||
|
r = client.post("/tunings/", json={"name": name}, headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
return r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTuneByInstrument:
|
||||||
|
def test_create_entry(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
tuning = make_tuning(client, "double C")
|
||||||
|
r = client.post(f'/tunes/{tune["id"]}/instruments',
|
||||||
|
json={"instrument_id": banjo_id, "tuning_id": tuning["id"], "callable": True},
|
||||||
|
headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
body = r.get_json()
|
||||||
|
assert body["instrument_name"] == "banjo"
|
||||||
|
assert body["tuning"] == "double C"
|
||||||
|
assert body["callable"] is True
|
||||||
|
|
||||||
|
def test_create_with_musician(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
m = make_musician(client)
|
||||||
|
r = client.post(f'/tunes/{tune["id"]}/instruments',
|
||||||
|
json={"instrument_id": banjo_id, "learned_from_id": m["id"]},
|
||||||
|
headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.get_json()["learned_from"] == "Earl Scruggs"
|
||||||
|
|
||||||
|
def test_update_entry(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
tuning = make_tuning(client, "sawmill")
|
||||||
|
entry = client.post(f'/tunes/{tune["id"]}/instruments',
|
||||||
|
json={"instrument_id": banjo_id, "callable": False},
|
||||||
|
headers=AUTH).get_json()
|
||||||
|
r = client.patch(f'/tunes/{tune["id"]}/instruments/{entry["id"]}',
|
||||||
|
json={"callable": True, "tuning_id": tuning["id"]}, headers=AUTH)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.get_json()["callable"] is True
|
||||||
|
assert r.get_json()["tuning"] == "sawmill"
|
||||||
|
|
||||||
|
def test_delete_entry(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
entry = client.post(f'/tunes/{tune["id"]}/instruments',
|
||||||
|
json={"instrument_id": banjo_id}, headers=AUTH).get_json()
|
||||||
|
r = client.delete(f'/tunes/{tune["id"]}/instruments/{entry["id"]}', headers=AUTH)
|
||||||
|
assert r.status_code == 200
|
||||||
|
remaining = client.get(f'/tunes/{tune["id"]}/instruments').get_json()
|
||||||
|
assert all(e["id"] != entry["id"] for e in remaining)
|
||||||
|
|
||||||
|
def test_unique_constraint(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
client.post(f'/tunes/{tune["id"]}/instruments',
|
||||||
|
json={"instrument_id": banjo_id}, headers=AUTH)
|
||||||
|
r = client.post(f'/tunes/{tune["id"]}/instruments',
|
||||||
|
json={"instrument_id": banjo_id}, headers=AUTH)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstrumentNotes:
|
||||||
|
def test_add_and_list_notes(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
entry = client.post(f'/tunes/{tune["id"]}/instruments',
|
||||||
|
json={"instrument_id": banjo_id}, headers=AUTH).get_json()
|
||||||
|
client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes',
|
||||||
|
json={"note": "tricky B part"}, headers=AUTH)
|
||||||
|
client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes',
|
||||||
|
json={"note": "watch the timing"}, headers=AUTH)
|
||||||
|
notes = client.get(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes').get_json()
|
||||||
|
assert len(notes) == 2
|
||||||
|
|
||||||
|
def test_update_note(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
entry = client.post(f'/tunes/{tune["id"]}/instruments',
|
||||||
|
json={"instrument_id": banjo_id}, headers=AUTH).get_json()
|
||||||
|
note = client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes',
|
||||||
|
json={"note": "old"}, headers=AUTH).get_json()
|
||||||
|
r = client.patch(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes/{note["id"]}',
|
||||||
|
json={"note": "new"}, headers=AUTH)
|
||||||
|
assert r.get_json()["note"] == "new"
|
||||||
|
|
||||||
|
def test_delete_note(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
entry = client.post(f'/tunes/{tune["id"]}/instruments',
|
||||||
|
json={"instrument_id": banjo_id}, headers=AUTH).get_json()
|
||||||
|
note = client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes',
|
||||||
|
json={"note": "to delete"}, headers=AUTH).get_json()
|
||||||
|
r = client.delete(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes/{note["id"]}',
|
||||||
|
headers=AUTH)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_notes_included_in_entry_to_dict(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
entry = client.post(f'/tunes/{tune["id"]}/instruments',
|
||||||
|
json={"instrument_id": banjo_id}, headers=AUTH).get_json()
|
||||||
|
client.post(f'/tunes/{tune["id"]}/instruments/{entry["id"]}/notes',
|
||||||
|
json={"note": "my note"}, headers=AUTH)
|
||||||
|
# Notes appear in the instrument entry and in the parent tune
|
||||||
|
tune_data = client.get(f'/tunes/{tune["id"]}').get_json()
|
||||||
|
inst_entry = tune_data["instruments"][0]
|
||||||
|
assert len(inst_entry["notes"]) == 1
|
||||||
|
assert inst_entry["notes"][0]["note"] == "my note"
|
||||||
70
tests/test_sources.py
Normal file
70
tests/test_sources.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Tests for /sources endpoints."""
|
||||||
|
from tests.conftest import AUTH, BAD_AUTH
|
||||||
|
|
||||||
|
|
||||||
|
def create_source(client, name="Doc Watson"):
|
||||||
|
r = client.post("/sources/", json={"name": name}, headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
return r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSources:
|
||||||
|
def test_create(self, client):
|
||||||
|
s = create_source(client)
|
||||||
|
assert s["name"] == "Doc Watson"
|
||||||
|
assert "id" in s
|
||||||
|
|
||||||
|
def test_list(self, client):
|
||||||
|
create_source(client, "Doc Watson")
|
||||||
|
create_source(client, "Tony Rice")
|
||||||
|
r = client.get("/sources/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
names = [s["name"] for s in r.get_json()]
|
||||||
|
assert "Doc Watson" in names
|
||||||
|
assert "Tony Rice" in names
|
||||||
|
|
||||||
|
def test_list_is_public(self, client):
|
||||||
|
r = client.get("/sources/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_create_requires_auth(self, client):
|
||||||
|
r = client.post("/sources/", json={"name": "test"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
def test_update(self, client):
|
||||||
|
s = create_source(client)
|
||||||
|
r = client.patch(f'/sources/{s["id"]}', json={"name": "Doc Watson Jr."}, headers=AUTH)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.get_json()["name"] == "Doc Watson Jr."
|
||||||
|
|
||||||
|
def test_delete(self, client):
|
||||||
|
s = create_source(client)
|
||||||
|
r = client.delete(f'/sources/{s["id"]}', headers=AUTH)
|
||||||
|
assert r.status_code == 200
|
||||||
|
remaining = [x["id"] for x in client.get("/sources/").get_json()]
|
||||||
|
assert s["id"] not in remaining
|
||||||
|
|
||||||
|
def test_tune_with_source(self, client):
|
||||||
|
s = create_source(client, "Clarence Ashley")
|
||||||
|
r = client.post("/tunes/", json={"name": "Coo Coo Bird", "key": "D", "source_id": s["id"]}, headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
tune = r.get_json()
|
||||||
|
assert tune["source"]["name"] == "Clarence Ashley"
|
||||||
|
assert tune["source_id"] == s["id"]
|
||||||
|
|
||||||
|
def test_filter_by_source(self, client):
|
||||||
|
s = create_source(client, "Clarence Ashley")
|
||||||
|
client.post("/tunes/", json={"name": "Coo Coo Bird", "source_id": s["id"]}, headers=AUTH)
|
||||||
|
client.post("/tunes/", json={"name": "Salt Creek"}, headers=AUTH)
|
||||||
|
r = client.get(f'/tunes/?source_id={s["id"]}')
|
||||||
|
results = r.get_json()
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "Coo Coo Bird"
|
||||||
|
|
||||||
|
def test_filter_by_modal(self, client):
|
||||||
|
client.post("/tunes/", json={"name": "Modal Tune", "modal": True}, headers=AUTH)
|
||||||
|
client.post("/tunes/", json={"name": "Regular Tune", "modal": False}, headers=AUTH)
|
||||||
|
r = client.get("/tunes/?modal=true")
|
||||||
|
names = [t["name"] for t in r.get_json()]
|
||||||
|
assert "Modal Tune" in names
|
||||||
|
assert "Regular Tune" not in names
|
||||||
133
tests/test_sub.py
Normal file
133
tests/test_sub.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""Tests for notes, references, and musicians sub-resource endpoints."""
|
||||||
|
from tests.conftest import AUTH
|
||||||
|
|
||||||
|
|
||||||
|
def make_tune(client):
|
||||||
|
r = client.post("/tunes/", json={"name": "Sub Test Tune", "key": "G"}, headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
return r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def make_musician(client, name="Doc Watson"):
|
||||||
|
r = client.post("/musicians/", json={"name": name}, headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
return r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Notes ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestNotes:
|
||||||
|
def test_add_note(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
r = client.post(f'/tunes/{tune["id"]}/notes',
|
||||||
|
json={"note": "remember the B part"}, headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.get_json()["note"] == "remember the B part"
|
||||||
|
|
||||||
|
def test_list_notes(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
client.post(f'/tunes/{tune["id"]}/notes', json={"note": "note 1"}, headers=AUTH)
|
||||||
|
client.post(f'/tunes/{tune["id"]}/notes', json={"note": "note 2"}, headers=AUTH)
|
||||||
|
r = client.get(f'/tunes/{tune["id"]}/notes', headers=AUTH)
|
||||||
|
assert len(r.get_json()) == 2
|
||||||
|
|
||||||
|
def test_update_note(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
note = client.post(f'/tunes/{tune["id"]}/notes',
|
||||||
|
json={"note": "old"}, headers=AUTH).get_json()
|
||||||
|
r = client.patch(f'/tunes/{tune["id"]}/notes/{note["id"]}',
|
||||||
|
json={"note": "new"}, headers=AUTH)
|
||||||
|
assert r.get_json()["note"] == "new"
|
||||||
|
|
||||||
|
def test_delete_note(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
note = client.post(f'/tunes/{tune["id"]}/notes',
|
||||||
|
json={"note": "to delete"}, headers=AUTH).get_json()
|
||||||
|
r = client.delete(f'/tunes/{tune["id"]}/notes/{note["id"]}', headers=AUTH)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_note_on_missing_tune_returns_404(self, client):
|
||||||
|
r = client.post("/tunes/99999/notes", json={"note": "x"}, headers=AUTH)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── References ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestReferences:
|
||||||
|
def test_add_reference(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
r = client.post(f'/tunes/{tune["id"]}/references',
|
||||||
|
json={"link": "https://youtube.com/abc", "site": "YouTube"},
|
||||||
|
headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
assert r.get_json()["musicians"] == []
|
||||||
|
|
||||||
|
def test_add_reference_with_musicians(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
m1 = make_musician(client, "Doc Watson")
|
||||||
|
m2 = make_musician(client, "Merle Watson")
|
||||||
|
r = client.post(f'/tunes/{tune["id"]}/references',
|
||||||
|
json={"link": "https://example.com", "site": "YouTube",
|
||||||
|
"musicians": [{"id": m1["id"]}, {"id": m2["id"]}]},
|
||||||
|
headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
names = [m["name"] for m in r.get_json()["musicians"]]
|
||||||
|
assert "Doc Watson" in names
|
||||||
|
assert "Merle Watson" in names
|
||||||
|
|
||||||
|
def test_list_references(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
client.post(f'/tunes/{tune["id"]}/references',
|
||||||
|
json={"link": "https://a.com"}, headers=AUTH)
|
||||||
|
client.post(f'/tunes/{tune["id"]}/references',
|
||||||
|
json={"link": "https://b.com"}, headers=AUTH)
|
||||||
|
r = client.get(f'/tunes/{tune["id"]}/references', headers=AUTH)
|
||||||
|
assert len(r.get_json()) == 2
|
||||||
|
|
||||||
|
def test_update_reference(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
ref = client.post(f'/tunes/{tune["id"]}/references',
|
||||||
|
json={"link": "https://old.com", "site": "Old"},
|
||||||
|
headers=AUTH).get_json()
|
||||||
|
r = client.patch(f'/tunes/{tune["id"]}/references/{ref["id"]}',
|
||||||
|
json={"site": "New"}, headers=AUTH)
|
||||||
|
assert r.get_json()["site"] == "New"
|
||||||
|
|
||||||
|
def test_delete_reference(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
ref = client.post(f'/tunes/{tune["id"]}/references',
|
||||||
|
json={"link": "https://del.com"}, headers=AUTH).get_json()
|
||||||
|
r = client.delete(f'/tunes/{tune["id"]}/references/{ref["id"]}', headers=AUTH)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── Musicians on references ───────────────────────────────────
|
||||||
|
|
||||||
|
class TestReferenceMusicians:
|
||||||
|
def _make_ref(self, client, tune_id):
|
||||||
|
r = client.post(f'/tunes/{tune_id}/references',
|
||||||
|
json={"link": "https://example.com"}, headers=AUTH)
|
||||||
|
return r.get_json()
|
||||||
|
|
||||||
|
def test_add_musician_to_reference(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
ref = self._make_ref(client, tune["id"])
|
||||||
|
m = make_musician(client, "Tony Rice")
|
||||||
|
r = client.post(f'/tunes/{tune["id"]}/references/{ref["id"]}/musicians',
|
||||||
|
json={"musician_id": m["id"]}, headers=AUTH)
|
||||||
|
assert r.status_code == 200
|
||||||
|
names = [x["name"] for x in r.get_json()["musicians"]]
|
||||||
|
assert "Tony Rice" in names
|
||||||
|
|
||||||
|
def test_remove_musician_from_reference(self, client):
|
||||||
|
tune = make_tune(client)
|
||||||
|
m = make_musician(client, "Tony Rice")
|
||||||
|
ref = self._make_ref(client, tune["id"])
|
||||||
|
# add musician via the join endpoint
|
||||||
|
client.post(f'/tunes/{tune["id"]}/references/{ref["id"]}/musicians',
|
||||||
|
json={"musician_id": m["id"]}, headers=AUTH)
|
||||||
|
r = client.delete(
|
||||||
|
f'/tunes/{tune["id"]}/references/{ref["id"]}/musicians/{m["id"]}',
|
||||||
|
headers=AUTH)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.get_json()["musicians"] == []
|
||||||
188
tests/test_tunes.py
Normal file
188
tests/test_tunes.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
"""Tests for /tunes endpoints."""
|
||||||
|
from tests.conftest import AUTH, BAD_AUTH
|
||||||
|
|
||||||
|
|
||||||
|
def create_tune(client, **kwargs):
|
||||||
|
data = {"name": "Cluck Old Hen", "key": "A", **kwargs}
|
||||||
|
r = client.post("/tunes/", json=data, headers=AUTH)
|
||||||
|
assert r.status_code == 201, r.get_json()
|
||||||
|
return r.get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def get_instrument_id(client, name):
|
||||||
|
instruments = client.get("/instruments").get_json()
|
||||||
|
return next(i["id"] for i in instruments if i["name"] == name)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestAuth:
|
||||||
|
def test_missing_key_rejected_on_write(self, client):
|
||||||
|
r = client.post("/tunes/", json={"name": "Test"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
def test_wrong_key_rejected_on_write(self, client):
|
||||||
|
r = client.post("/tunes/", json={"name": "Test"}, headers=BAD_AUTH)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
def test_reads_are_public(self, client):
|
||||||
|
assert client.get("/tunes/").status_code == 200
|
||||||
|
|
||||||
|
def test_correct_key_accepted_on_write(self, client):
|
||||||
|
r = client.post("/tunes/", json={"name": "Test"}, headers=AUTH)
|
||||||
|
assert r.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
# ── Health ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestHealth:
|
||||||
|
def test_health(self, client):
|
||||||
|
assert client.get("/health").get_json()["status"] == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Instruments lookup ────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestInstrumentsLookup:
|
||||||
|
def test_list_instruments(self, client):
|
||||||
|
names = [i["name"] for i in client.get("/instruments").get_json()]
|
||||||
|
assert "banjo" in names
|
||||||
|
assert "fiddle" in names
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tune CRUD ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestTuneCRUD:
|
||||||
|
def test_create_minimal(self, client):
|
||||||
|
tune = create_tune(client)
|
||||||
|
assert tune["name"] == "Cluck Old Hen"
|
||||||
|
assert tune["key"] == "A"
|
||||||
|
assert tune["modal"] is None
|
||||||
|
assert tune["source"] is None
|
||||||
|
assert tune["instruments"] == []
|
||||||
|
assert tune["notes"] == []
|
||||||
|
assert tune["references"] == []
|
||||||
|
|
||||||
|
def test_create_with_instrument(self, client):
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
tuning = client.post("/tunings/", json={"name": "double C"}, headers=AUTH).get_json()
|
||||||
|
tune = create_tune(client, instruments=[{
|
||||||
|
"instrument_id": banjo_id,
|
||||||
|
"tuning_id": tuning["id"],
|
||||||
|
"callable": True,
|
||||||
|
}])
|
||||||
|
assert len(tune["instruments"]) == 1
|
||||||
|
assert tune["instruments"][0]["instrument_name"] == "banjo"
|
||||||
|
assert tune["instruments"][0]["tuning"] == "double C"
|
||||||
|
|
||||||
|
def test_create_with_two_instruments(self, client):
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
fiddle_id = get_instrument_id(client, "fiddle")
|
||||||
|
tune = create_tune(client, instruments=[
|
||||||
|
{"instrument_id": banjo_id, "tuning": "double C"},
|
||||||
|
{"instrument_id": fiddle_id, "tuning": "cross"},
|
||||||
|
])
|
||||||
|
assert len(tune["instruments"]) == 2
|
||||||
|
|
||||||
|
def test_create_with_notes_and_references(self, client):
|
||||||
|
tune = create_tune(client,
|
||||||
|
notes=[{"note": "tricky B part"}],
|
||||||
|
references=[{"link": "https://youtube.com/x", "site": "YouTube"}],
|
||||||
|
)
|
||||||
|
assert tune["notes"][0]["note"] == "tricky B part"
|
||||||
|
assert tune["references"][0]["site"] == "YouTube"
|
||||||
|
|
||||||
|
def test_get_single(self, client):
|
||||||
|
created = create_tune(client)
|
||||||
|
r = client.get(f'/tunes/{created["id"]}', headers=AUTH)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.get_json()["id"] == created["id"]
|
||||||
|
|
||||||
|
def test_get_nonexistent_returns_404(self, client):
|
||||||
|
assert client.get("/tunes/99999", headers=AUTH).status_code == 404
|
||||||
|
|
||||||
|
def test_list_returns_all(self, client):
|
||||||
|
create_tune(client, name="Tune A")
|
||||||
|
create_tune(client, name="Tune B")
|
||||||
|
names = [t["name"] for t in client.get("/tunes/", headers=AUTH).get_json()]
|
||||||
|
assert "Tune A" in names and "Tune B" in names
|
||||||
|
|
||||||
|
def test_patch_core_fields(self, client):
|
||||||
|
tune = create_tune(client)
|
||||||
|
r = client.patch(f'/tunes/{tune["id"]}',
|
||||||
|
json={"key": "G", "name": "New Name", "modal": True},
|
||||||
|
headers=AUTH)
|
||||||
|
assert r.status_code == 200
|
||||||
|
u = r.get_json()
|
||||||
|
assert u["key"] == "G" and u["name"] == "New Name" and u["modal"] is True
|
||||||
|
|
||||||
|
def test_delete(self, client):
|
||||||
|
tune = create_tune(client)
|
||||||
|
r = client.delete(f'/tunes/{tune["id"]}', headers=AUTH)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert client.get(f'/tunes/{tune["id"]}', headers=AUTH).status_code == 404
|
||||||
|
|
||||||
|
def test_delete_cascades_to_instrument_entry(self, client):
|
||||||
|
from app.models import TuneByInstrument
|
||||||
|
from app.extensions import db
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
tune = create_tune(client, instruments=[{"instrument_id": banjo_id}])
|
||||||
|
client.delete(f'/tunes/{tune["id"]}', headers=AUTH)
|
||||||
|
assert db.session.query(TuneByInstrument).filter_by(tune_id=tune["id"]).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Filtering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TestFiltering:
|
||||||
|
def test_filter_by_key(self, client):
|
||||||
|
create_tune(client, name="D tune", key="D")
|
||||||
|
create_tune(client, name="G tune", key="G")
|
||||||
|
results = client.get("/tunes/?key=D", headers=AUTH).get_json()
|
||||||
|
assert all(t["key"] == "D" for t in results)
|
||||||
|
|
||||||
|
def test_search_by_name(self, client):
|
||||||
|
create_tune(client, name="Whiskey Before Breakfast")
|
||||||
|
create_tune(client, name="Salt Creek")
|
||||||
|
results = client.get("/tunes/?search=whiskey", headers=AUTH).get_json()
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "Whiskey Before Breakfast"
|
||||||
|
|
||||||
|
def test_filter_by_instrument(self, client):
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
create_tune(client, name="Has Banjo",
|
||||||
|
instruments=[{"instrument_id": banjo_id, "tuning": "standard"}])
|
||||||
|
create_tune(client, name="No Banjo")
|
||||||
|
results = client.get(f"/tunes/?instrument_id={banjo_id}", headers=AUTH).get_json()
|
||||||
|
names = [t["name"] for t in results]
|
||||||
|
assert "Has Banjo" in names and "No Banjo" not in names
|
||||||
|
|
||||||
|
def test_filter_by_tuning(self, client):
|
||||||
|
fiddle_id = get_instrument_id(client, "fiddle")
|
||||||
|
cross_tuning = client.post("/tunings/", json={"name": "cross"}, headers=AUTH).get_json()
|
||||||
|
std_tuning = client.post("/tunings/", json={"name": "standard"}, headers=AUTH).get_json()
|
||||||
|
create_tune(client, name="Cross", instruments=[{"instrument_id": fiddle_id, "tuning_id": cross_tuning["id"]}])
|
||||||
|
create_tune(client, name="Std", instruments=[{"instrument_id": fiddle_id, "tuning_id": std_tuning["id"]}])
|
||||||
|
results = client.get(f'/tunes/?instrument_id={fiddle_id}&tuning={cross_tuning["id"]}', headers=AUTH).get_json()
|
||||||
|
assert len(results) == 1 and results[0]["name"] == "Cross"
|
||||||
|
|
||||||
|
def test_filter_callable(self, client):
|
||||||
|
banjo_id = get_instrument_id(client, "banjo")
|
||||||
|
create_tune(client, name="Can call", instruments=[{"instrument_id": banjo_id, "callable": True}])
|
||||||
|
create_tune(client, name="Cannot call", instruments=[{"instrument_id": banjo_id, "callable": False}])
|
||||||
|
results = client.get(f"/tunes/?instrument_id={banjo_id}&callable=true", headers=AUTH).get_json()
|
||||||
|
names = [t["name"] for t in results]
|
||||||
|
assert "Can call" in names and "Cannot call" not in names
|
||||||
|
|
||||||
|
def test_filter_to_learn(self, client):
|
||||||
|
fiddle_id = get_instrument_id(client, "fiddle")
|
||||||
|
create_tune(client, name="To learn", instruments=[{"instrument_id": fiddle_id, "to_learn": True}])
|
||||||
|
create_tune(client, name="Learned", instruments=[{"instrument_id": fiddle_id, "to_learn": False}])
|
||||||
|
results = client.get(f"/tunes/?instrument_id={fiddle_id}&to_learn=true", headers=AUTH).get_json()
|
||||||
|
names = [t["name"] for t in results]
|
||||||
|
assert "To learn" in names and "Learned" not in names
|
||||||
|
|
||||||
|
def test_filter_modal(self, client):
|
||||||
|
create_tune(client, name="Modal", modal=True)
|
||||||
|
create_tune(client, name="Regular", modal=False)
|
||||||
|
results = client.get("/tunes/?modal=true", headers=AUTH).get_json()
|
||||||
|
names = [t["name"] for t in results]
|
||||||
|
assert "Modal" in names and "Regular" not in names
|
||||||
456
uv.lock
generated
Normal file
456
uv.lock
generated
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alembic"
|
||||||
|
version = "1.18.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mako" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blinker"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
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 = "flask"
|
||||||
|
version = "3.1.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "blinker" },
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "itsdangerous" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-cors"
|
||||||
|
version = "6.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/47/03/4e464a50860f9adf08b5c1d3479cb8ea1f12af2aa69535c7042c6e628135/flask_cors-6.0.5.tar.gz", hash = "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601", size = 101386, upload-time = "2026-06-08T20:20:17.765Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/55/5bb1a2d918e9f02f131e47a59032bae70e48050e986e941511fd737a935c/flask_cors-6.0.5-py3-none-any.whl", hash = "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8", size = 16692, upload-time = "2026-06-08T20:20:16.247Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask-sqlalchemy"
|
||||||
|
version = "3.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "greenlet"
|
||||||
|
version = "3.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
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 = "itsdangerous"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "mako"
|
||||||
|
version = "1.3.12"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
|
||||||
|
{ 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 = "packaging"
|
||||||
|
version = "26.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg2-binary"
|
||||||
|
version = "2.9.12"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-flask"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fb/23/32b36d2f769805c0f3069ca8d9eeee77b27fcf86d41d40c6061ddce51c7d/pytest-flask-1.3.0.tar.gz", hash = "sha256:58be1c97b21ba3c4d47e0a7691eb41007748506c36bf51004f78df10691fa95e", size = 35816, upload-time = "2023-10-23T14:53:20.696Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/03/7a917fda3d0e96b4e80ab1f83a6628ec4ee4a882523b49417d3891bacc9e/pytest_flask-1.3.0-py3-none-any.whl", hash = "sha256:c0e36e6b0fddc3b91c4362661db83fa694d1feb91fa505475be6732b5bc8c253", size = 13105, upload-time = "2023-10-23T14:53:18.959Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "repertory-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "alembic" },
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "flask-cors" },
|
||||||
|
{ name = "flask-sqlalchemy" },
|
||||||
|
{ name = "psycopg2-binary" },
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-flask" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "alembic", specifier = ">=1.13" },
|
||||||
|
{ name = "flask", specifier = ">=3.0" },
|
||||||
|
{ name = "flask-cors", specifier = ">=4.0" },
|
||||||
|
{ name = "flask-sqlalchemy", specifier = ">=3.1" },
|
||||||
|
{ name = "psycopg2-binary", specifier = ">=2.9" },
|
||||||
|
{ name = "python-dotenv", specifier = ">=1.0" },
|
||||||
|
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
|
{ name = "pytest-flask", specifier = ">=1.3" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlalchemy"
|
||||||
|
version = "2.0.50"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "werkzeug"
|
||||||
|
version = "3.1.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
|
||||||
|
]
|
||||||
10
wsgi.py
Normal file
10
wsgi.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
"""Entry point — loads .env then starts Flask."""
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue