From 3679c22aaa6e8d859e089e467443cf3e17b02a90 Mon Sep 17 00:00:00 2001 From: Fede Date: Mon, 21 Sep 2026 16:39:51 -0300 Subject: [PATCH] Add Postgres and a due-check worker to Docker Compose. Support DATABASE_URL, run web against PostgreSQL, and keep endpoints probed on an interval via a dedicated worker service. Co-authored-by: Cursor --- .env.example | 6 + .github/workflows/ci.yml | 19 +++ Dockerfile | 10 +- README.md | 77 ++++++------ config/settings.py | 25 +++- config/test_settings_helpers.py | 16 +++ docker-compose.yml | 43 ++++++- docker/entrypoint.sh | 3 + docker/wait_for_db.py | 49 ++++++++ docker/worker.sh | 8 ++ .../management/commands/run_check_worker.py | 46 +++++++ monitoring/tests.py | 9 ++ poetry.lock | 119 +++++++++++++++++- pyproject.toml | 2 + 14 files changed, 378 insertions(+), 54 deletions(-) create mode 100755 docker/wait_for_db.py create mode 100755 docker/worker.sh create mode 100644 monitoring/management/commands/run_check_worker.py diff --git a/.env.example b/.env.example index 7ff5b99..cab01b1 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,9 @@ DJANGO_DEBUG=true DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 DJANGO_CSRF_TRUSTED_ORIGINS= DJANGO_TIME_ZONE=UTC + +# Leave empty for local sqlite; Compose overrides with Postgres. +DATABASE_URL= DJANGO_SQLITE_PATH=db.sqlite3 CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 @@ -11,5 +14,8 @@ APOLLO_DEMO_USERNAME=apollo APOLLO_DEMO_PASSWORD=apollo APOLLO_SEED_ON_STARTUP=false +# Due-check worker loop (seconds) +CHECK_INTERVAL_SECONDS=60 + SESSION_COOKIE_SECURE=false CSRF_COOKIE_SECURE=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeaa9f9..6a0d6f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,22 @@ jobs: backend: name: Backend tests runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: apollo + POSTGRES_USER: apollo + POSTGRES_PASSWORD: apollo + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U apollo -d apollo" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgres://apollo:apollo@localhost:5432/apollo steps: - uses: actions/checkout@v4 @@ -63,3 +79,6 @@ jobs: - name: Build image run: docker build -t apollo:ci . + + - name: Validate compose file + run: docker compose config -q diff --git a/Dockerfile b/Dockerfile index aeeb4a4..9c4cbda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,17 +29,19 @@ COPY config ./config COPY monitoring ./monitoring COPY --from=frontend /app/frontend/dist ./frontend/dist COPY docker/entrypoint.sh /entrypoint.sh +COPY docker/worker.sh /worker.sh +COPY docker/wait_for_db.py /wait_for_db.py -RUN chmod +x /entrypoint.sh \ +RUN chmod +x /entrypoint.sh /worker.sh \ && mkdir -p /data ENV DJANGO_DEBUG=false \ DJANGO_ALLOWED_HOSTS=* \ - DJANGO_SQLITE_PATH=/data/db.sqlite3 \ - APOLLO_SEED_ON_STARTUP=true + APOLLO_SEED_ON_STARTUP=true \ + CHECK_INTERVAL_SECONDS=60 EXPOSE 8000 -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ CMD curl -fsS http://127.0.0.1:8000/api/health/ || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index 5c1f0e3..29e7b40 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ Django + React API Health Monitor (monolith). - **Backend:** Django 6 + DRF + httpx + Token auth + WhiteNoise + Gunicorn (Poetry) - **Frontend:** React + Vite + Redux Toolkit (pnpm) -- **Ops:** Docker, docker-compose, GitHub Actions CI, `.env` configuration -- **Features:** scheduled checks, webhook alerts, uptime dashboard +- **Data:** SQLite (local default) or **PostgreSQL** via `DATABASE_URL` +- **Ops:** Docker Compose (`db` + `web` + `worker`), GitHub Actions CI +- **Features:** due checks, webhook alerts, uptime dashboard, background check worker -## Quick start (local) +## Quick start (local / SQLite) ```bash cp .env.example .env @@ -18,77 +19,76 @@ poetry run python manage.py migrate poetry run python manage.py seed pnpm --dir frontend install -``` - -Run API + Vite: - -```bash poetry run python manage.py runserver pnpm --dir frontend dev ``` -Open http://localhost:5173 and sign in with **`apollo` / `apollo`**. +Open http://localhost:5173 — login **`apollo` / `apollo`**. -## Docker +## Docker (Postgres + web + worker) ```bash cp .env.example .env -# set DJANGO_SECRET_KEY and DJANGO_DEBUG=false for real deploys +# set a real DJANGO_SECRET_KEY for deploys docker compose up --build ``` -App: http://localhost:8000 (SPA + API in one container) -Login: `apollo` / `apollo` (seeded on startup when `APOLLO_SEED_ON_STARTUP=true`) +Services: + +| Service | Role | +|---------|------| +| `db` | PostgreSQL 16 | +| `web` | Gunicorn API + SPA | +| `worker` | Loop: `check_endpoints --due` every `CHECK_INTERVAL_SECONDS` | -Useful: +App: http://localhost:8000 +Login: `apollo` / `apollo` (seeded by web when `APOLLO_SEED_ON_STARTUP=true`) ```bash -docker compose logs -f web +docker compose logs -f worker docker compose down ``` ## Environment -See [`.env.example`](.env.example). Important keys: +See [`.env.example`](.env.example). | Variable | Purpose | |----------|---------| -| `DJANGO_SECRET_KEY` | Django secret | -| `DJANGO_DEBUG` | `true` / `false` | -| `DJANGO_ALLOWED_HOSTS` | Comma-separated hosts | -| `DJANGO_SQLITE_PATH` | SQLite file path (`/data/db.sqlite3` in Docker) | -| `CORS_ALLOWED_ORIGINS` | Browser origins allowed to call the API | -| `APOLLO_DEMO_USERNAME` / `APOLLO_DEMO_PASSWORD` | Seeded demo user | -| `APOLLO_SEED_ON_STARTUP` | Run `seed` on container boot | +| `DATABASE_URL` | Postgres URL (empty = SQLite) | +| `DJANGO_SQLITE_PATH` | SQLite path when `DATABASE_URL` is empty | +| `CHECK_INTERVAL_SECONDS` | Worker loop interval (default 60) | +| `APOLLO_SEED_ON_STARTUP` | Seed demo user/endpoints on web boot | +| `DJANGO_SECRET_KEY` / `DJANGO_DEBUG` / `DJANGO_ALLOWED_HOSTS` | Django core | ## Auth -API uses **Token authentication** (`Authorization: Token `). +Token auth (`Authorization: Token `). Public: `/api/health/`, `/api/auth/login/`. -| Method | Path | Auth | -|--------|------|------| -| POST | `/api/auth/login/` | public | -| POST | `/api/auth/logout/` | token | -| GET | `/api/auth/me/` | token | -| GET | `/api/health/` | public | -| GET | `/api/dashboard/` | token | -| * | `/api/endpoints/…` | token | +## Scheduling -## Scheduling (cron / host) +**Docker:** the `worker` service runs due checks automatically. + +**Host cron (SQLite/Poetry):** ```bash * * * * * cd /path/to/Apollo && poetry run python manage.py check_endpoints --due ``` -Inside Docker you can add a second service or host cron hitting the same volume/DB. +**One-shot / debug:** + +```bash +poetry run python manage.py run_check_worker --once +poetry run python manage.py run_check_worker --interval 30 +``` ## CI -GitHub Actions (`.github/workflows/ci.yml`) runs: +`.github/workflows/ci.yml`: -1. Poetry + pytest +1. Pytest against Postgres service 2. pnpm build -3. `docker build` +3. `docker build` + `docker compose config` ## Useful commands @@ -96,6 +96,5 @@ GitHub Actions (`.github/workflows/ci.yml`) runs: poetry run pytest poetry run python manage.py check_endpoints --due pnpm --dir frontend build -poetry run python manage.py collectstatic --noinput -poetry run gunicorn config.wsgi:application --bind 0.0.0.0:8000 +docker compose up --build ``` diff --git a/config/settings.py b/config/settings.py index 6b7a374..b8d447a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -85,13 +85,25 @@ def env_list(name: str, default: str = "") -> list[str]: WSGI_APPLICATION = "config.wsgi.application" -sqlite_path = env("DJANGO_SQLITE_PATH", str(BASE_DIR / "db.sqlite3")) -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": sqlite_path, +DATABASE_URL = env("DATABASE_URL") +if DATABASE_URL: + import dj_database_url + + DATABASES = { + "default": dj_database_url.parse( + DATABASE_URL, + conn_max_age=600, + conn_health_checks=True, + ) + } +else: + sqlite_path = env("DJANGO_SQLITE_PATH", str(BASE_DIR / "db.sqlite3")) + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": sqlite_path, + } } -} AUTH_PASSWORD_VALIDATORS = [ { @@ -153,6 +165,7 @@ def env_list(name: str, default: str = "") -> list[str]: APOLLO_DEMO_USERNAME = env("APOLLO_DEMO_USERNAME", "apollo") or "apollo" APOLLO_DEMO_PASSWORD = env("APOLLO_DEMO_PASSWORD", "apollo") or "apollo" APOLLO_SEED_ON_STARTUP = env_bool("APOLLO_SEED_ON_STARTUP", False) +CHECK_INTERVAL_SECONDS = int(env("CHECK_INTERVAL_SECONDS", "60") or "60") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", not DEBUG) diff --git a/config/test_settings_helpers.py b/config/test_settings_helpers.py index 54e88b7..83d7fbb 100644 --- a/config/test_settings_helpers.py +++ b/config/test_settings_helpers.py @@ -21,3 +21,19 @@ def test_whitenoise_is_enabled() -> None: assert "whitenoise.storage.CompressedStaticFilesStorage" in str( settings.STORAGES["staticfiles"]["BACKEND"] ) + + +def test_check_interval_setting() -> None: + from django.conf import settings + + assert isinstance(settings.CHECK_INTERVAL_SECONDS, int) + assert settings.CHECK_INTERVAL_SECONDS >= 1 + + +def test_database_url_parsing() -> None: + import dj_database_url + + config = dj_database_url.parse("postgres://apollo:apollo@db:5432/apollo") + assert config["ENGINE"].endswith("postgresql") + assert config["NAME"] == "apollo" + assert config["HOST"] == "db" diff --git a/docker-compose.yml b/docker-compose.yml index d67927e..b3e6991 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,19 @@ services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: apollo + POSTGRES_USER: apollo + POSTGRES_PASSWORD: apollo + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U apollo -d apollo"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + web: build: . ports: @@ -8,12 +23,32 @@ services: environment: DJANGO_DEBUG: "false" DJANGO_ALLOWED_HOSTS: "*" - DJANGO_SQLITE_PATH: /data/db.sqlite3 + DATABASE_URL: postgres://apollo:apollo@db:5432/apollo APOLLO_SEED_ON_STARTUP: "${APOLLO_SEED_ON_STARTUP:-true}" CORS_ALLOWED_ORIGINS: "${CORS_ALLOWED_ORIGINS:-http://localhost:8000,http://127.0.0.1:8000}" - volumes: - - apollo_data:/data + CHECK_INTERVAL_SECONDS: "${CHECK_INTERVAL_SECONDS:-60}" + depends_on: + db: + condition: service_healthy + restart: unless-stopped + + worker: + build: . + command: ["/worker.sh"] + env_file: + - .env + environment: + DJANGO_DEBUG: "false" + DJANGO_ALLOWED_HOSTS: "*" + DATABASE_URL: postgres://apollo:apollo@db:5432/apollo + APOLLO_SEED_ON_STARTUP: "false" + CHECK_INTERVAL_SECONDS: "${CHECK_INTERVAL_SECONDS:-60}" + depends_on: + db: + condition: service_healthy + web: + condition: service_started restart: unless-stopped volumes: - apollo_data: + postgres_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 896244d..9fe3c56 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,6 +1,9 @@ #!/bin/sh set -eu +echo "Waiting for database..." +python /wait_for_db.py + echo "Running migrations..." python manage.py migrate --noinput diff --git a/docker/wait_for_db.py b/docker/wait_for_db.py new file mode 100755 index 0000000..0ce0bed --- /dev/null +++ b/docker/wait_for_db.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python +"""Wait until the configured database accepts connections.""" + +from __future__ import annotations + +import os +import sys +import time + + +def main() -> int: + database_url = os.environ.get("DATABASE_URL", "").strip() + if not database_url: + print("No DATABASE_URL set; skipping DB wait (sqlite mode).") + return 0 + + import dj_database_url + import psycopg + + config = dj_database_url.parse(database_url) + if not config.get("ENGINE", "").endswith("postgresql"): + print(f"DATABASE_URL engine is {config.get('ENGINE')}; skipping wait.") + return 0 + + retries = int(os.environ.get("DB_WAIT_RETRIES", "30")) + delay = float(os.environ.get("DB_WAIT_DELAY", "2")) + + conninfo = ( + f"host={config['HOST']} port={config.get('PORT') or 5432} " + f"dbname={config['NAME']} user={config['USER']} " + f"password={config.get('PASSWORD') or ''}" + ) + + for attempt in range(1, retries + 1): + try: + with psycopg.connect(conninfo, connect_timeout=3) as connection: + connection.execute("SELECT 1") + print(f"Database is ready (attempt {attempt}).") + return 0 + except Exception as exc: # noqa: BLE001 - retry until timeout + print(f"Waiting for database ({attempt}/{retries}): {exc}") + time.sleep(delay) + + print("Database did not become ready in time.", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docker/worker.sh b/docker/worker.sh new file mode 100755 index 0000000..aaea066 --- /dev/null +++ b/docker/worker.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +echo "Waiting for database..." +python /wait_for_db.py + +echo "Starting check worker (interval=${CHECK_INTERVAL_SECONDS:-60}s)..." +exec python manage.py run_check_worker --interval "${CHECK_INTERVAL_SECONDS:-60}" diff --git a/monitoring/management/commands/run_check_worker.py b/monitoring/management/commands/run_check_worker.py new file mode 100644 index 0000000..dcddc56 --- /dev/null +++ b/monitoring/management/commands/run_check_worker.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import time + +from django.conf import settings +from django.core.management import call_command +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "Continuously run due endpoint health checks." + + def add_arguments(self, parser) -> None: + parser.add_argument( + "--interval", + type=int, + default=None, + help="Seconds between due-check passes (default: CHECK_INTERVAL_SECONDS).", + ) + parser.add_argument( + "--once", + action="store_true", + help="Run a single due-check pass and exit.", + ) + + def handle(self, *args, **options) -> None: + interval = options["interval"] or getattr(settings, "CHECK_INTERVAL_SECONDS", 60) + interval = max(5, int(interval)) + + self.stdout.write( + self.style.NOTICE(f"Check worker interval: {interval}s (due endpoints only)") + ) + + while True: + started = time.monotonic() + try: + call_command("check_endpoints", due=True) + except Exception as exc: # noqa: BLE001 - keep worker alive + self.stderr.write(self.style.ERROR(f"Check pass failed: {exc}")) + + if options["once"]: + break + + elapsed = time.monotonic() - started + sleep_for = max(1.0, interval - elapsed) + time.sleep(sleep_for) diff --git a/monitoring/tests.py b/monitoring/tests.py index 274edea..e73cb2a 100644 --- a/monitoring/tests.py +++ b/monitoring/tests.py @@ -505,3 +505,12 @@ def test_dashboard_metrics(api_client) -> None: assert response.data["summary"]["uptime_percent"] == 50.0 assert response.data["endpoints"][0]["name"] == "Alpha" assert response.data["endpoints"][0]["uptime_percent"] == 50.0 + + +@pytest.mark.django_db +@patch("monitoring.management.commands.run_check_worker.call_command") +def test_run_check_worker_once(mock_call: MagicMock) -> None: + from django.core.management import call_command + + call_command("run_check_worker", once=True, interval=5) + mock_call.assert_called_once_with("check_endpoints", due=True) diff --git a/poetry.lock b/poetry.lock index e156004..eaa3773 100644 --- a/poetry.lock +++ b/poetry.lock @@ -60,6 +60,21 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "dj-database-url" +version = "3.1.2" +description = "Use Database URLs in your Django Application." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "dj_database_url-3.1.2-py3-none-any.whl", hash = "sha256:544e015fee3efa5127a1eb1cca465f4ace578265b3671fe61d0ed7dbafb5ec8a"}, + {file = "dj_database_url-3.1.2.tar.gz", hash = "sha256:63c20e4bbaa51690dfd4c8d189521f6bf6bc9da9fcdb23d95d2ee8ee87f9ec62"}, +] + +[package.dependencies] +django = ">=4.2" + [[package]] name = "django" version = "6.1.1" @@ -284,6 +299,108 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "psycopg" +version = "3.3.6" +description = "PostgreSQL database adapter for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631"}, + {file = "psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2"}, +] + +[package.dependencies] +psycopg-binary = {version = "3.3.6", optional = true, markers = "implementation_name != \"pypy\" and extra == \"binary\""} +typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""} +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +binary = ["psycopg-binary (==3.3.6) ; implementation_name != \"pypy\""] +c = ["psycopg-c (==3.3.6) ; implementation_name != \"pypy\""] +dev = ["ast-comments (>=1.1.2)", "black (>=26.1.0)", "codespell (>=2.2)", "cython-lint (>=0.21)", "dnspython (>=2.1)", "flake8 (>=4.0)", "isort-psycopg (>=0.0.3)", "isort[colors] (>=6.0)", "mypy (>=2.1.0)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "types-shapely (>=2.0)", "wheel (>=0.37)"] +docs = ["Sphinx (>=9.1)", "furo (==2025.12.19)", "sphinx-autobuild (>=2025.8.25)", "sphinx-autodoc-typehints (>=3.10.2)"] +pool = ["psycopg-pool"] +test = ["anyio (>=4.0)", "mypy (>=2.1.0) ; implementation_name != \"pypy\"", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] + +[[package]] +name = "psycopg-binary" +version = "3.3.6" +description = "PostgreSQL database adapter for Python -- C optimisation distribution" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "implementation_name != \"pypy\"" +files = [ + {file = "psycopg_binary-3.3.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7beb3e41c9a1e509f3ed85263386588cbe3e975aa67be21f79f44fd35ffaeefc"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa73160077345ec21b3f51e8e24b3de2e99586217e497629326eb9b2ea88c52e"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f87dbdc42e78ee0f7ea180c03f8c78e80a949e373066629bd90fefff10552dff"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a9348c5b43a3bb5ef8c2e89d5237c9c87eeafb01d338c84a7aebbc5cd0313299"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a52991594ac4db888c7d39bccef331797e30cb31a95cae02cf2607f83a42dc2"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ea8beeb5541780b4b50b462eeacbc4f594ce3b911dc20c81c75f267876f71d2"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:198a48e68cc99ccac03ba95ac857e73aa66f3bf6be77019fafb0832a05f7ad03"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fa34eb47969297471db7b7f193622c7e3ee839ec05abd05f1fe104d5b1b1dcf4"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b979a42815410432420275412633960807178b1ce26591a16ce06e78a5bd4bb2"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:889e42acec10450185e0cdfb396f375e2c1a8d7737c114830a7fde4654f59e30"}, + {file = "psycopg_binary-3.3.6-cp310-cp310-win_amd64.whl", hash = "sha256:cbd5f73073ed19c378d4c35499db1e3e703a5b1a324e521204065967bfaa7a18"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:be4f9b3c9338ac5dd217c5847e21521b396c8117f78dc420d495a5c49bbef874"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0535693ce476a722b718b002d5d2c27d47e71ca945276ac194409c98e74c492"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3c9e663b2e800e3218994cf948c11bcc2844e6491b34aa80d089baf6531827bf"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a2e44a342d2aee40508e28a563d8961c39d9bbd8cae36d8578f0a3c6658aab0f"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f598f19fa9a91540b5cee17932ffd227b7b53a481605bcc4573c0eafa647300"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff05561e4a067d35507dc5c90f1deb2ec1c9703ac5cccc1bc26e08a197f9c5a"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:566dd827f17728efdf7d88a5b066f815170f6fdad13967ae952842d90e6aaa9f"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9b2f11794e017ce340934e35de46181c46ef71ec75ea3d85dd75cd836761c01e"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:910ace140e3e7b7596898d083f37a8fe90c5c40684252ad4e682364b2cd3deba"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37e517c146b185f9c0c6e8d0a0ebbdeeeb67896af28466e032bc810d0c7dc7a7"}, + {file = "psycopg_binary-3.3.6-cp311-cp311-win_amd64.whl", hash = "sha256:c7f92daa0d2a1c76f07264abddf8cbabd30152a2f09c3270e50f0c7efdf5dcac"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52"}, + {file = "psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138"}, + {file = "psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781"}, + {file = "psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bf8c8481d026b85dd70c5fa7dde85b2333aed0b32a2602bcd38a900cbd78a49c"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b599defe9190b17e9907c8b4d114c181e702c87efcd1b8a0ad40971cdcc4634a"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b8ece331509f7a975b90501f41e83ad905e4141753fedf3f2711b2bc70a8efbc"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c61617eaae0112ca154da87ffb99b73af2c74067acac28dfb9a4455b019dff2e"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6d19cb4999d03231e8730a5f66c8f5068bc3b532677eb39dab0f600bff3e312"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8cbb54454dbf1bbf2ff08dd7693e8d94ac94b1a20f70f4b3b813d52ecb5cbc1"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dc75da5a20951049f7b773145f998f69d181adad9c58a0ff36e0cf1d73c10e10"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:955e3dd94da361e052d2e49acf591017158dc8f8ed2c8a42c2e3943403c39dc2"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:c7753871eb57e6a5f4646f6168590c6653073dea5e9e720b201c8875332df4c8"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:303732e798fe6729f8e12021b9c96107df8e95ecec4dd487c67b98ec2a59435e"}, + {file = "psycopg_binary-3.3.6-cp315-cp315-win_amd64.whl", hash = "sha256:2f122603f36050937982abf9668d8bc4769a79f7c93a65013b1c49f1cab7b56b"}, +] + [[package]] name = "pygments" version = "2.21.0" @@ -415,4 +532,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "1a0e79d4d9ace848034e745a508b2da8e07b5eb22197c14f9f727171a14453d5" +content-hash = "b976fc92dd8e04e0fe3848c7996775e58dcb6b5c85f292e0a2b22955d79ee9db" diff --git a/pyproject.toml b/pyproject.toml index b604347..783a365 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,8 @@ httpx = "^0.28.1" gunicorn = "^26.2.0" whitenoise = "^6.12.0" python-dotenv = "^1.2.3" +psycopg = {extras = ["binary"], version = "^3.3.6"} +dj-database-url = "^3.1.2" [tool.poetry.group.dev.dependencies] factory-boy = "^3.3.3"