heartwood every commit a ring

Replace Django stack with single-binary axum port

59a3b318 by Isaac Bythewood · 5 days ago

Replace Django stack with single-binary axum port

- Wholesale tree swap from the analytics-rust working copy: removes the
  Django apps (accounts, properties, collector, pages), settings split,
  pyproject/uv lockfile, and Python templates; adds the rust src tree,
  Cargo manifest, sqlx migrations, minijinja templates, frontend bundle
  config, and rust Dockerfile.
- New `./analytics migrate <django.sqlite3> [--force]` subcommand
  imports an existing Django analytics SQLite into the hot-field schema,
  preserving property UUIDs so embedded snippets keep working without
  rotation. Also wired as `make migrate FROM=...`.
- DB-IP geoip download walks back up to two months on 404 so a
  first-of-the-month boot doesn't degrade enrichment until the next
  restart.
- README, CLAUDE.md, and Makefile updated to drop references to the
  prior Django implementation; LICENSE preserved.
deleted .dockerignore
@@ -1,7 +0,0 @@/.venv/media/node_modules/db.sqlite3/analytics/static/analytics/static_maps/.env
modified .gitignore
@@ -1,9 +1,9 @@__pycache__//.venv/media/node_modules/db.sqlite3/db.mmdb/analytics/static/analytics/static_maps/target/dist/data/frontend/node_modules/frontend/dist/static_maps/.env.env.local*.log
modified CLAUDE.md
@@ -4,50 +4,120 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co## What This IsSelf-hosted website analytics service. Tracks page views, clicks, scrolls, sessions, and custom events. Users create "Properties" (tracked sites), embed a collector script, and view dashboards with date range filtering, comparisons, charts, maps, and PDF report export.## Development Commands- **`make`** — installs deps (uv + bun) if needed, creates DB, then runs Django dev server and Vite watch concurrently- **`uv run python manage.py runserver`** — Django only- **`bun run dev`** — Vite watch only- **`uv run python manage.py migrate`** — apply migrations- **`uv run python manage.py makemigrations`** — generate migrations- **`make pull`** — rsync production DB, GeoIP database, and media from server- **`make push`** — push to all git remotes- **Deps:** Python via `uv`, JS via `bun`. No tests, no linters.- **Default login:** admin / admin## Django Apps| App | Purpose ||---|---|| `analytics` | Project config (settings, URLs, templates, ASGI/WSGI, context processors, headless chromium PDF utils via `analytics/chromium.py`). Vite outputs land in `analytics/static/`. || `accounts` | Custom `User` model (UUID PK, extends `AbstractUser`). Login/logout/signup views. || `properties` | Core domain. `Property` model (a tracked site, UUID PK, belongs to User) and `Event` model (stores all analytics events as JSON in `data` field). Dashboard views, query helpers (`queries.py`), and custom card management. || `collector` | Single `POST /collect/` endpoint (CSRF-exempt, CORS-open). Receives events from client JS, enriches with GeoIP + user-agent parsing, filters bots, saves to DB. || `pages` | Static pages: home, changelog, documentation, favicon, robots, sitemap. |Self-hosted website analytics, single-operator. Tracks page views, clicks, scrolls, sessions, and custom events. Properties (tracked sites) embed a collector script that POSTs JSON to `/collect`. The dashboard renders metric cards, time-series charts, world map, and PDF/markdown reports for any date range.Single-binary axum app, no Django, no multi-user. Password from `.env`. Originally a Django service; data from that era was migrated in via `./analytics migrate <django.sqlite3>` and the Django code has been retired.## Commands- **Dev server:** `make run` (runs Vite watch + cargo run concurrently on port 8000)- **Production build:** `make build` (Vite assets + topojson maps + release binary)- **Run release binary:** `make start`- **Pull production data:** `make pull` (rsync db + geoip from `git remote server`)- **Rebuild map data:** `make maps` (regenerates per-country topojson)- **Seed fake data:** `make seed` (creates/refreshes a "Seed Test" property with realistic events; override `SESSIONS=2000 DAYS=60`)- **Import a Django DB:** `make migrate FROM=<path-to-django.sqlite3> [FORCE=1]` — one-shot import that preserves property UUIDs (so embedded snippets keep working). Same logic is exposed on the binary as `./analytics migrate <path> [--force]` so the production Docker image can run it.- **Docker build:** `sudo docker build .`There are no tests or linters configured.## Architecture**Data model:** Everything centers on `Property` → `Event`. Events have a `event` type string (session_start, page_view, click, scroll, page_leave, or custom) and a `data` JSONField holding all event-specific key-value pairs. There is no separate table per event type — all querying is done via Django's JSON field lookups (`data__url`, `data__referrer`, `data__utm_source`, etc.).**Backend:** Single-binary axum app (`src/main.rs`). Async sqlx + sqlite (WAL, `synchronous=NORMAL`, `busy_timeout=5s`, foreign keys on) for both reads and writes. Schema lives in `migrations/0001_initial.sql` and is applied automatically on boot. State is shared via `AppState` (template env, db pool, cookie key, geoip + ua parsers, dashboard cache, config).**Auth:** Single password from `ANALYTICS_PASSWORD` in `.env`. Login form posts to `/login`; on success a signed cookie (`SameSite=Strict`, 30-day max-age) is set. The cookie payload is just `1:<exp-timestamp>`, signed with `tower-cookies`. The cookie key is `ANALYTICS_COOKIE_SECRET` if set, else derived from the password via SHA-512 (so changing the password invalidates sessions). No CSRF tokens — `SameSite=Strict` is the protection.**Data model:** Three tables. `properties` holds tracked sites (UUID PK, name, custom_cards JSON, is_protected, is_public). `events` stores human events with hot fields extracted into typed columns (url, referrer, user_agent, country, region, city, lat/lon, screen size, platform/browser/device, utm_*, time_on_page_ms, user_id) plus an `extra` JSON blob for custom keys. `bot_events` is a separate table with the small subset of fields useful for bot reporting; bot traffic is routed there at write time so human dashboard queries never have to filter it. `meta` is a key-value table for things like the Proprium property id.**Collector flow:** `POST /collect` (also `/collect/` for compat) accepts `{collectorId, event, data}` JSON. It looks up the property, normalizes the referrer to a bare hostname, runs GeoIP enrichment (via `maxminddb` against `data/db.mmdb`) on `session_start`, parses the user agent (via `uaparser` against `data/regexes.yaml`), and either inserts into `events` or `bot_events` based on the parsed bot flag. CORS is fully open on this endpoint. Client IP is read from `X-Forwarded-For` first, then `X-Real-IP`.**Self-tracking (Proprium):** On first boot the binary auto-creates a "Proprium" property with `is_protected=1` and stores its UUID in the `meta` table. The base template renders a collector snippet pointing at that property when `BASE_URL` is set, so the app tracks its own usage like any other site.**Auto-downloads:** On boot, two best-effort background tasks download `data/db.mmdb` (DB-IP City Lite, CC-BY-4.0, no signup) if missing or older than 30 days, and `data/regexes.yaml` (canonical ua-parser regexes from the `ua-parser/uap-core` repo, Apache-2.0) if missing. Failures are logged and the server still boots — UA parsing falls back to a substring heuristic and GeoIP enrichment is skipped.**Templates:** Jinja2 templates in `templates/` rendered by minijinja with a Jinja2-faithful HTML formatter so `/` is not escaped to `&#x2f;` (matches blog/darkfurrow). The `vite_asset` global resolves hashed asset names by reading `dist/.vite/manifest.json` — re-read per call in debug builds (Vite watcher rebuilds show up immediately), cached at startup in release builds.**Frontend pipeline:** Vite (run from `frontend/`) builds `frontend/static_src/` into `dist/`. Four entry points: `base` (Bootstrap 5 SCSS + monaspace font + the bootstrap JS shell), `pages` (marketing pages), `properties` (dashboard charts + map), `collector` (the public embed script). Output filenames are content-hashed and served at `/static/`.**Map data:** `frontend/scripts/build_maps.js` (run at Docker build time via `bun run build:maps`) downloads Natural Earth admin-0 110m + admin-1 10m GeoJSON and writes per-country TopoJSON files into `static_maps/`. The Rust binary serves that directory at `/static_maps/`. The world view ships with the dashboard; per-country admin-1 is lazy-fetched on click.**Dashboard cache:** `moka` in-memory cache keyed by `dash:<property>:<updated_at>:<dates>:<filter_url>`, 5-minute TTL. `?report=pdf|md` bypasses the cache so exports match the live view.**PDF generation:** `src/pdf.rs` spawns chrome-headless-shell via `--print-to-pdf` against a temp `.html` file. Chromium is located via `CHROMIUM_BIN`, then PATH search, then a `/opt/playwright-browsers/` glob fallback (lifted directly from blog/darkfurrow).**Request logging:** `src/main.rs::log_requests` middleware prints `time METHOD STATUS latency path` per request with ANSI-colored status codes (green 2xx, cyan 3xx, yellow 4xx, red 5xx). Sub-microsecond cost.## Layout```analytics/├── Cargo.toml, Cargo.lock        # rust deps├── Makefile, README.md           # top-level├── migrations/                   # sqlx migrations (0001_initial.sql)├── src/                          # rust source│   ├── main.rs        # axum app, AppState, middleware, route table│   ├── auth.rs        # signed cookie, login/logout, is_authenticated│   ├── views.rs       # /properties + /<id> dashboard (CRUD + render)│   ├── pages.rs       # /, /changelog, /documentation, /favicon.ico, /robots.txt, /sitemap.xml│   ├── collector.rs   # POST /collect with UA + GeoIP enrichment + bot routing│   ├── db.rs          # sqlx pool init, migrate, ensure_proprium│   ├── models.rs      # Property + PropertyRow structs│   ├── queries.rs     # dashboard aggregations (in-progress port of properties/queries.py)│   ├── geoip.rs       # maxminddb wrapper + DB-IP auto-download + reload│   ├── ua.rs          # uap-rs wrapper + regexes auto-download│   ├── cache.rs       # moka 5-min dashboard cache│   ├── markdown.rs    # comrak wrapper│   ├── pdf.rs         # chrome-headless-shell subprocess│   └── templates.rs   # minijinja env, vite_asset, url_for, jinja2-compat formatter├── templates/                    # minijinja-compatible jinja2│   ├── base.html      # layout│   ├── includes/      # collector, messages, social│   ├── registration/  # login form│   ├── properties/    # properties list + dashboard│   └── pages/         # home, changelog, documentation├── frontend/                     # JS pipeline (package.json, vite.config.js, static_src/)│   ├── static_src/│   │   ├── base/       # bootstrap scss + base scripts (entry: index.js)│   │   ├── pages/      # marketing styles│   │   ├── properties/ # dashboard chart + map JS│   │   └── collector/  # public embed│   └── scripts/build_maps.js   # natural earth → topojson├── dist/                         # vite build output (gitignored, served at /static/)├── static_maps/                  # topojson per country (gitignored, served at /static_maps/)├── data/                         # sqlite db + geoip mmdb + regexes.yaml at runtime (gitignored)├── target/                       # cargo build output (gitignored)├── Dockerfile, docker-compose.yml└── samplefiles/                  # Caddyfile.sample, env.sample, post-receive.sample```The binary reads `templates/`, `dist/`, `migrations/`, and `static_maps/` from cwd by default. Override the project root with `ANALYTICS_ROOT=<path>` and the data directory with `ANALYTICS_DATA_DIR=<path>` (production sets the latter to `/data`).## Key Routes**Collection flow:** Client sites include `collector.js` (bundled via Vite's `collector` entry point). The script sets a `collectoruserid` cookie, fires session_start (on first visit), page_view, click, scroll, and page_leave events to `POST /collect/`. The server-side view enriches session_start events with GeoIP data (uses `db.mmdb`, auto-downloaded on container start by the `refresh_geoip` management command from DB-IP City Lite — CC-BY-4.0, no signup) and parses user-agent strings into platform/browser/device fields. Bot traffic is silently dropped.- `/` — marketing home (redirects to `/properties` when authenticated)- `/login`, `/logout` — single-password auth- `/properties` — list + create + delete + custom cards + visibility toggle (auth required)- `/<property-id>` — dashboard (auth required unless property is public). Accepts `?date_start`, `?date_end`, `?date_range`, `?filter_url`, `?report=pdf|md`.- `/collect`, `/collect/` — public collector endpoint (CORS-open, accepts JSON POST)- `/changelog`, `/documentation`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml` — static pages- `/static/*` — Vite assets (1y cache header)- `/static_maps/*` — topojson per country (1y cache header)**Dashboard:** `properties/views.py:property()` is the main dashboard view. It filters events by date range, computes current vs. previous period comparisons, and builds all chart/list data server-side. Standard metric cards are computed in `properties/queries.py`. Properties can have custom event cards (stored as JSON on the Property model). The dashboard supports a `?report` query param to generate PDF reports via a headless Chromium subprocess (`analytics/chromium.py`).## Tooling**Frontend:** Vite bundles 4 entry points (`base`, `pages`, `properties`, `collector`) from each app's `static_src/` directory. Uses Bootstrap 5 (SCSS), Chart.js for graphs, `d3-geo` + `topojson-client` for the world map. Output goes to `analytics/static/`. WhiteNoise serves static files.- **Rust deps:** managed with `cargo` (`Cargo.toml`, `Cargo.lock`)- **JS deps:** managed with `bun`, run from `frontend/` (`frontend/package.json`, `frontend/bun.lock`)- **Production:** Docker (`rust:alpine` builder + `alpine:3.23` runtime, `chromium` apk for PDF), deployed via `git push server master` triggering a post-receive hook that runs `docker compose up --build --detach`. Data persisted to `/srv/data/analytics/`.**Map data:** `analytics/scripts/build_maps.js` (run via `bun run build:maps` at Docker build time) downloads Natural Earth admin-0 110m + admin-1 10m GeoJSON, encodes as TopoJSON, and writes per-country files to `analytics/static_maps/` (added to `STATICFILES_DIRS`). The world view is always loaded; per-country admin-1 (states/provinces) is lazy-fetched on click. Attribution: DB-IP link in the footer, Natural Earth is public domain.## Status of the port**Settings:** Split into `analytics/settings/__init__.py` (shared), `development.py`, and `production.py`. Dev uses SQLite at project root; production uses SQLite at `/data/db/db.sqlite3`. `DJANGO_SETTINGS_MODULE` defaults to development; production sets it via `.env`.The port is feature-complete against the original Django version:**Production:** Single Docker container (Alpine 3.21 base) running Gunicorn with Uvicorn workers (ASGI). Caddy as reverse proxy. Data persisted to `/srv/data/analytics/`. Deployed via `git push server master` triggering a post-receive hook.- Login, properties CRUD, dashboard with all 16 metric/chart/list aggregations, custom cards, public toggle, collector with UA + GeoIP enrichment + bot routing, PDF + markdown report export, world map with admin-1 drill-down, self-tracking via Proprium, auto-download of geoip + uaparser regexes, Dockerfile + samplefiles for the standard `git push server master` deploy.- The dashboard's frontend JS (Chart.js charts, d3-geo + topojson world map, custom-card form, public toggle, filter chips, date selector) is the original code; the server-side context shape matches what those scripts expect.## Key ConventionsPossible future work (none of it required for parity):- All model PKs are UUIDs.- Event data is schemaless — the `data` JSONField is the extensibility point. New event attributes are added by sending them from the client; no migration needed.- The `collector` context processor injects `collector_server` and `collector_id` into all templates so the app can track its own usage (property named "Proprium").- GeoIP is auto-downloaded on container start (DB-IP City Lite). Monthly refresh via host cron (`docker exec analytics_web python manage.py refresh_geoip --force`). If the file is missing or stale, the collector silently skips enrichment — non-fatal.- Chromium is bundled in the Docker image (Alpine `chromium` package) for server-side PDF generation. `analytics/chromium.py` wraps a headless Chromium subprocess — no Playwright dependency.- Custom-cards POST currently accepts a JSON array but the in-page form uses checkbox names. The form-handler JS posts JSON, so this works; if the JS ever stops sending JSON, the endpoint would need to accept form-encoded too.- `?report=pdf` template links the production base_url for assets. In dev, leave `BASE_URL` unset and chromium will inline the relative paths.- Auto-download of the DB-IP mmdb fails on the first day or two of every month while DB-IP rolls the new file. The code retries on next boot — for production set up `restic-status`-style monthly cron via `make pull` if you need stronger guarantees.
added Cargo.lock
@@ -0,0 +1,3376 @@# This file is automatically @generated by Cargo.# It is not intended for manual editing.version = 4[[package]]name = "adler2"version = "2.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"[[package]]name = "aho-corasick"version = "1.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"dependencies = [ "memchr",][[package]]name = "allocator-api2"version = "0.2.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"[[package]]name = "analytics"version = "0.1.0"dependencies = [ "anyhow", "axum", "base64", "chrono", "chrono-tz", "dotenvy", "flate2", "futures-util", "hmac", "maxminddb", "mime_guess", "minijinja", "moka", "rand 0.8.6", "reqwest", "serde", "serde_json", "serde_urlencoded", "sha2", "sqlx", "tar", "tempfile", "thiserror", "tokio", "tower", "tower-cookies", "tower-http", "tracing", "tracing-subscriber", "uaparser", "urlencoding", "uuid",][[package]]name = "android_system_properties"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"dependencies = [ "libc",][[package]]name = "anyhow"version = "1.0.102"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"[[package]]name = "async-lock"version = "3.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"dependencies = [ "event-listener", "event-listener-strategy", "pin-project-lite",][[package]]name = "atoi"version = "2.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"dependencies = [ "num-traits",][[package]]name = "atomic-waker"version = "1.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"[[package]]name = "autocfg"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"[[package]]name = "axum"version = "0.8.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"dependencies = [ "axum-core", "axum-macros", "bytes", "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", "hyper", "hyper-util", "itoa", "matchit", "memchr", "mime", "percent-encoding", "pin-project-lite", "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", "sync_wrapper", "tokio", "tower", "tower-layer", "tower-service", "tracing",][[package]]name = "axum-core"version = "0.5.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"dependencies = [ "bytes", "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", "sync_wrapper", "tower-layer", "tower-service", "tracing",][[package]]name = "axum-macros"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "base64"version = "0.22.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"[[package]]name = "base64ct"version = "1.8.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"[[package]]name = "bitflags"version = "2.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"dependencies = [ "serde_core",][[package]]name = "block-buffer"version = "0.10.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"dependencies = [ "generic-array",][[package]]name = "bumpalo"version = "3.20.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"[[package]]name = "byteorder"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"[[package]]name = "bytes"version = "1.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"[[package]]name = "cc"version = "1.2.61"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"dependencies = [ "find-msvc-tools", "shlex",][[package]]name = "cfg-if"version = "1.0.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"[[package]]name = "cfg_aliases"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"[[package]]name = "chrono"version = "0.4.44"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"dependencies = [ "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", "windows-link",][[package]]name = "chrono-tz"version = "0.10.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"dependencies = [ "chrono", "phf",][[package]]name = "concurrent-queue"version = "2.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"dependencies = [ "crossbeam-utils",][[package]]name = "const-oid"version = "0.9.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"[[package]]name = "convert_case"version = "0.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"[[package]]name = "cookie"version = "0.18.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"dependencies = [ "base64", "hmac", "percent-encoding", "rand 0.8.6", "sha2", "subtle", "time", "version_check",][[package]]name = "core-foundation-sys"version = "0.8.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"[[package]]name = "cpufeatures"version = "0.2.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"dependencies = [ "libc",][[package]]name = "crc"version = "3.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"dependencies = [ "crc-catalog",][[package]]name = "crc-catalog"version = "2.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"[[package]]name = "crc32fast"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"dependencies = [ "cfg-if",][[package]]name = "crossbeam-channel"version = "0.5.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"dependencies = [ "crossbeam-utils",][[package]]name = "crossbeam-epoch"version = "0.9.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"dependencies = [ "crossbeam-utils",][[package]]name = "crossbeam-queue"version = "0.3.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"dependencies = [ "crossbeam-utils",][[package]]name = "crossbeam-utils"version = "0.8.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"[[package]]name = "crypto-common"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"dependencies = [ "generic-array", "typenum",][[package]]name = "der"version = "0.7.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"dependencies = [ "const-oid", "pem-rfc7468", "zeroize",][[package]]name = "deranged"version = "0.5.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"dependencies = [ "powerfmt",][[package]]name = "derive_more"version = "0.99.20"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"dependencies = [ "convert_case", "proc-macro2", "quote", "rustc_version", "syn",][[package]]name = "digest"version = "0.10.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"dependencies = [ "block-buffer", "const-oid", "crypto-common", "subtle",][[package]]name = "displaydoc"version = "0.2.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "dotenvy"version = "0.15.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"[[package]]name = "either"version = "1.15.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"dependencies = [ "serde",][[package]]name = "equivalent"version = "1.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"[[package]]name = "errno"version = "0.3.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"dependencies = [ "libc", "windows-sys 0.61.2",][[package]]name = "etcetera"version = "0.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"dependencies = [ "cfg-if", "home", "windows-sys 0.48.0",][[package]]name = "event-listener"version = "5.4.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"dependencies = [ "concurrent-queue", "parking", "pin-project-lite",][[package]]name = "event-listener-strategy"version = "0.5.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"dependencies = [ "event-listener", "pin-project-lite",][[package]]name = "fastrand"version = "2.4.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"[[package]]name = "filetime"version = "0.2.27"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"dependencies = [ "cfg-if", "libc", "libredox",][[package]]name = "find-msvc-tools"version = "0.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"[[package]]name = "flate2"version = "1.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"dependencies = [ "crc32fast", "miniz_oxide",][[package]]name = "flume"version = "0.11.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"dependencies = [ "futures-core", "futures-sink", "spin",][[package]]name = "foldhash"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"[[package]]name = "form_urlencoded"version = "1.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"dependencies = [ "percent-encoding",][[package]]name = "futures-channel"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"dependencies = [ "futures-core", "futures-sink",][[package]]name = "futures-core"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"[[package]]name = "futures-executor"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"dependencies = [ "futures-core", "futures-task", "futures-util",][[package]]name = "futures-intrusive"version = "0.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"dependencies = [ "futures-core", "lock_api", "parking_lot",][[package]]name = "futures-io"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"[[package]]name = "futures-macro"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "futures-sink"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"[[package]]name = "futures-task"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"[[package]]name = "futures-util"version = "0.3.32"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"dependencies = [ "futures-core", "futures-io", "futures-macro", "futures-sink", "futures-task", "memchr", "pin-project-lite", "slab",][[package]]name = "generic-array"version = "0.14.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"dependencies = [ "typenum", "version_check",][[package]]name = "getrandom"version = "0.2.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"dependencies = [ "cfg-if", "js-sys", "libc", "wasi", "wasm-bindgen",][[package]]name = "getrandom"version = "0.3.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 5.3.0", "wasip2", "wasm-bindgen",][[package]]name = "getrandom"version = "0.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", "wasip2", "wasip3",][[package]]name = "hashbrown"version = "0.15.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"dependencies = [ "allocator-api2", "equivalent", "foldhash",][[package]]name = "hashbrown"version = "0.17.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"[[package]]name = "hashlink"version = "0.10.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"dependencies = [ "hashbrown 0.15.5",][[package]]name = "heck"version = "0.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"[[package]]name = "hex"version = "0.4.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"[[package]]name = "hkdf"version = "0.12.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"dependencies = [ "hmac",][[package]]name = "hmac"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"dependencies = [ "digest",][[package]]name = "home"version = "0.5.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"dependencies = [ "windows-sys 0.61.2",][[package]]name = "http"version = "1.4.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"dependencies = [ "bytes", "itoa",][[package]]name = "http-body"version = "1.0.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"dependencies = [ "bytes", "http",][[package]]name = "http-body-util"version = "0.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"dependencies = [ "bytes", "futures-core", "http", "http-body", "pin-project-lite",][[package]]name = "http-range-header"version = "0.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"[[package]]name = "httparse"version = "1.10.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"[[package]]name = "httpdate"version = "1.0.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"[[package]]name = "hyper"version = "1.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "http", "http-body", "httparse", "httpdate", "itoa", "pin-project-lite", "smallvec", "tokio", "want",][[package]]name = "hyper-rustls"version = "0.27.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"dependencies = [ "http", "hyper", "hyper-util", "rustls", "tokio", "tokio-rustls", "tower-service", "webpki-roots",][[package]]name = "hyper-util"version = "0.1.20"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"dependencies = [ "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2", "tokio", "tower-service", "tracing",][[package]]name = "iana-time-zone"version = "0.1.65"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", "wasm-bindgen", "windows-core",][[package]]name = "iana-time-zone-haiku"version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"dependencies = [ "cc",][[package]]name = "icu_collections"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"dependencies = [ "displaydoc", "potential_utf", "utf8_iter", "yoke", "zerofrom", "zerovec",][[package]]name = "icu_locale_core"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec",][[package]]name = "icu_normalizer"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec",][[package]]name = "icu_normalizer_data"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"[[package]]name = "icu_properties"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"dependencies = [ "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", "zerotrie", "zerovec",][[package]]name = "icu_properties_data"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"[[package]]name = "icu_provider"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"dependencies = [ "displaydoc", "icu_locale_core", "writeable", "yoke", "zerofrom", "zerotrie", "zerovec",][[package]]name = "id-arena"version = "2.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"[[package]]name = "idna"version = "1.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"dependencies = [ "idna_adapter", "smallvec", "utf8_iter",][[package]]name = "idna_adapter"version = "1.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"dependencies = [ "icu_normalizer", "icu_properties",][[package]]name = "indexmap"version = "2.14.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"dependencies = [ "equivalent", "hashbrown 0.17.0", "serde", "serde_core",][[package]]name = "ipnet"version = "2.12.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"[[package]]name = "ipnetwork"version = "0.20.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"dependencies = [ "serde",][[package]]name = "itoa"version = "1.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"[[package]]name = "js-sys"version = "0.3.97"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf"dependencies = [ "cfg-if", "futures-util", "once_cell", "wasm-bindgen",][[package]]name = "lazy_static"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"dependencies = [ "spin",][[package]]name = "leb128fmt"version = "0.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"[[package]]name = "libc"version = "0.2.186"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"[[package]]name = "libm"version = "0.2.16"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"[[package]]name = "libredox"version = "0.1.16"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"dependencies = [ "bitflags", "libc", "plain", "redox_syscall 0.7.5",][[package]]name = "libsqlite3-sys"version = "0.30.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"dependencies = [ "cc", "pkg-config", "vcpkg",][[package]]name = "linux-raw-sys"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"[[package]]name = "litemap"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"[[package]]name = "lock_api"version = "0.4.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"dependencies = [ "scopeguard",][[package]]name = "log"version = "0.4.29"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"[[package]]name = "lru-slab"version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"[[package]]name = "matchers"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"dependencies = [ "regex-automata",][[package]]name = "matchit"version = "0.8.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"[[package]]name = "maxminddb"version = "0.24.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c"dependencies = [ "ipnetwork", "log", "memchr", "serde",][[package]]name = "md-5"version = "0.10.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"dependencies = [ "cfg-if", "digest",][[package]]name = "memchr"version = "2.8.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"[[package]]name = "memo-map"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"[[package]]name = "mime"version = "0.3.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"[[package]]name = "mime_guess"version = "2.0.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"dependencies = [ "mime", "unicase",][[package]]name = "minijinja"version = "2.19.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d"dependencies = [ "memo-map", "serde", "serde_json",][[package]]name = "miniz_oxide"version = "0.8.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"dependencies = [ "adler2", "simd-adler32",][[package]]name = "mio"version = "1.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"dependencies = [ "libc", "wasi", "windows-sys 0.61.2",][[package]]name = "moka"version = "0.12.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"dependencies = [ "async-lock", "crossbeam-channel", "crossbeam-epoch", "crossbeam-utils", "equivalent", "event-listener", "futures-util", "parking_lot", "portable-atomic", "smallvec", "tagptr", "uuid",][[package]]name = "nu-ansi-term"version = "0.50.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"dependencies = [ "windows-sys 0.61.2",][[package]]name = "num-bigint-dig"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"dependencies = [ "lazy_static", "libm", "num-integer", "num-iter", "num-traits", "rand 0.8.6", "smallvec", "zeroize",][[package]]name = "num-conv"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"[[package]]name = "num-integer"version = "0.1.46"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"dependencies = [ "num-traits",][[package]]name = "num-iter"version = "0.1.45"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"dependencies = [ "autocfg", "num-integer", "num-traits",][[package]]name = "num-traits"version = "0.2.19"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"dependencies = [ "autocfg", "libm",][[package]]name = "once_cell"version = "1.21.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"[[package]]name = "parking"version = "2.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"[[package]]name = "parking_lot"version = "0.12.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"dependencies = [ "lock_api", "parking_lot_core",][[package]]name = "parking_lot_core"version = "0.9.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"dependencies = [ "cfg-if", "libc", "redox_syscall 0.5.18", "smallvec", "windows-link",][[package]]name = "pem-rfc7468"version = "0.7.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"dependencies = [ "base64ct",][[package]]name = "percent-encoding"version = "2.3.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"[[package]]name = "phf"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"dependencies = [ "phf_shared",][[package]]name = "phf_shared"version = "0.12.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"dependencies = [ "siphasher",][[package]]name = "pin-project-lite"version = "0.2.17"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"[[package]]name = "pkcs1"version = "0.7.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"dependencies = [ "der", "pkcs8", "spki",][[package]]name = "pkcs8"version = "0.10.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"dependencies = [ "der", "spki",][[package]]name = "pkg-config"version = "0.3.33"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"[[package]]name = "plain"version = "0.2.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"[[package]]name = "portable-atomic"version = "1.13.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"[[package]]name = "potential_utf"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"dependencies = [ "zerovec",][[package]]name = "powerfmt"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"[[package]]name = "ppv-lite86"version = "0.2.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"dependencies = [ "zerocopy",][[package]]name = "prettyplease"version = "0.2.37"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"dependencies = [ "proc-macro2", "syn",][[package]]name = "proc-macro2"version = "1.0.106"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"dependencies = [ "unicode-ident",][[package]]name = "quinn"version = "0.11.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", "socket2", "thiserror", "tokio", "tracing", "web-time",][[package]]name = "quinn-proto"version = "0.11.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", "rand 0.9.4", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", "thiserror", "tinyvec", "tracing", "web-time",][[package]]name = "quinn-udp"version = "0.5.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", "windows-sys 0.60.2",][[package]]name = "quote"version = "1.0.45"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"dependencies = [ "proc-macro2",][[package]]name = "r-efi"version = "5.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"[[package]]name = "r-efi"version = "6.0.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"[[package]]name = "rand"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.4",][[package]]name = "rand"version = "0.9.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5",][[package]]name = "rand_chacha"version = "0.3.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"dependencies = [ "ppv-lite86", "rand_core 0.6.4",][[package]]name = "rand_chacha"version = "0.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"dependencies = [ "ppv-lite86", "rand_core 0.9.5",][[package]]name = "rand_core"version = "0.6.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"dependencies = [ "getrandom 0.2.17",][[package]]name = "rand_core"version = "0.9.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"dependencies = [ "getrandom 0.3.4",][[package]]name = "redox_syscall"version = "0.5.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"dependencies = [ "bitflags",][[package]]name = "redox_syscall"version = "0.7.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b"dependencies = [ "bitflags",][[package]]name = "regex"version = "1.12.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax",][[package]]name = "regex-automata"version = "0.4.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"dependencies = [ "aho-corasick", "memchr", "regex-syntax",][[package]]name = "regex-syntax"version = "0.8.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"[[package]]name = "reqwest"version = "0.12.28"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"dependencies = [ "base64", "bytes", "futures-core", "futures-util", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams", "web-sys", "webpki-roots",][[package]]name = "ring"version = "0.17.14"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"dependencies = [ "cc", "cfg-if", "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0",][[package]]name = "rsa"version = "0.9.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"dependencies = [ "const-oid", "digest", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", "pkcs8", "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize",][[package]]name = "rustc-hash"version = "2.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"[[package]]name = "rustc_version"version = "0.4.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"dependencies = [ "semver",][[package]]name = "rustix"version = "1.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2",][[package]]name = "rustls"version = "0.23.40"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"dependencies = [ "once_cell", "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize",][[package]]name = "rustls-pki-types"version = "1.14.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"dependencies = [ "web-time", "zeroize",][[package]]name = "rustls-webpki"version = "0.103.13"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"dependencies = [ "ring", "rustls-pki-types", "untrusted",][[package]]name = "rustversion"version = "1.0.22"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"[[package]]name = "ryu"version = "1.0.23"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"[[package]]name = "scopeguard"version = "1.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"[[package]]name = "semver"version = "1.0.28"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"[[package]]name = "serde"version = "1.0.228"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"dependencies = [ "serde_core", "serde_derive",][[package]]name = "serde_core"version = "1.0.228"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"dependencies = [ "serde_derive",][[package]]name = "serde_derive"version = "1.0.228"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "serde_json"version = "1.0.149"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"dependencies = [ "itoa", "memchr", "serde", "serde_core", "zmij",][[package]]name = "serde_path_to_error"version = "0.1.20"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"dependencies = [ "itoa", "serde", "serde_core",][[package]]name = "serde_urlencoded"version = "0.7.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"dependencies = [ "form_urlencoded", "itoa", "ryu", "serde",][[package]]name = "serde_yaml"version = "0.9.34+deprecated"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"dependencies = [ "indexmap", "itoa", "ryu", "serde", "unsafe-libyaml",][[package]]name = "sha1"version = "0.10.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"dependencies = [ "cfg-if", "cpufeatures", "digest",][[package]]name = "sha2"version = "0.10.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"dependencies = [ "cfg-if", "cpufeatures", "digest",][[package]]name = "sharded-slab"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"dependencies = [ "lazy_static",][[package]]name = "shlex"version = "1.3.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"[[package]]name = "signal-hook-registry"version = "1.4.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"dependencies = [ "errno", "libc",][[package]]name = "signature"version = "2.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"dependencies = [ "digest", "rand_core 0.6.4",][[package]]name = "simd-adler32"version = "0.3.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"[[package]]name = "siphasher"version = "1.0.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"[[package]]name = "slab"version = "0.4.12"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"[[package]]name = "smallvec"version = "1.15.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"dependencies = [ "serde",][[package]]name = "socket2"version = "0.6.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"dependencies = [ "libc", "windows-sys 0.61.2",][[package]]name = "spin"version = "0.9.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"dependencies = [ "lock_api",][[package]]name = "spki"version = "0.7.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"dependencies = [ "base64ct", "der",][[package]]name = "sqlx"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"dependencies = [ "sqlx-core", "sqlx-macros", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite",][[package]]name = "sqlx-core"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"dependencies = [ "base64", "bytes", "chrono", "crc", "crossbeam-queue", "either", "event-listener", "futures-core", "futures-intrusive", "futures-io", "futures-util", "hashbrown 0.15.5", "hashlink", "indexmap", "log", "memchr", "once_cell", "percent-encoding", "serde", "serde_json", "sha2", "smallvec", "thiserror", "tokio", "tokio-stream", "tracing", "url", "uuid",][[package]]name = "sqlx-macros"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", "syn",][[package]]name = "sqlx-macros-core"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"dependencies = [ "dotenvy", "either", "heck", "hex", "once_cell", "proc-macro2", "quote", "serde", "serde_json", "sha2", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "syn", "tokio", "url",][[package]]name = "sqlx-mysql"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"dependencies = [ "atoi", "base64", "bitflags", "byteorder", "bytes", "chrono", "crc", "digest", "dotenvy", "either", "futures-channel", "futures-core", "futures-io", "futures-util", "generic-array", "hex", "hkdf", "hmac", "itoa", "log", "md-5", "memchr", "once_cell", "percent-encoding", "rand 0.8.6", "rsa", "serde", "sha1", "sha2", "smallvec", "sqlx-core", "stringprep", "thiserror", "tracing", "uuid", "whoami",][[package]]name = "sqlx-postgres"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"dependencies = [ "atoi", "base64", "bitflags", "byteorder", "chrono", "crc", "dotenvy", "etcetera", "futures-channel", "futures-core", "futures-util", "hex", "hkdf", "hmac", "home", "itoa", "log", "md-5", "memchr", "once_cell", "rand 0.8.6", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", "thiserror", "tracing", "uuid", "whoami",][[package]]name = "sqlx-sqlite"version = "0.8.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"dependencies = [ "atoi", "chrono", "flume", "futures-channel", "futures-core", "futures-executor", "futures-intrusive", "futures-util", "libsqlite3-sys", "log", "percent-encoding", "serde", "serde_urlencoded", "sqlx-core", "thiserror", "tracing", "url", "uuid",][[package]]name = "stable_deref_trait"version = "1.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"[[package]]name = "stringprep"version = "0.1.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"dependencies = [ "unicode-bidi", "unicode-normalization", "unicode-properties",][[package]]name = "subtle"version = "2.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"[[package]]name = "syn"version = "2.0.117"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"dependencies = [ "proc-macro2", "quote", "unicode-ident",][[package]]name = "sync_wrapper"version = "1.0.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"dependencies = [ "futures-core",][[package]]name = "synstructure"version = "0.13.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "tagptr"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"[[package]]name = "tar"version = "0.4.45"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"dependencies = [ "filetime", "libc", "xattr",][[package]]name = "tempfile"version = "3.27.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2",][[package]]name = "thiserror"version = "2.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"dependencies = [ "thiserror-impl",][[package]]name = "thiserror-impl"version = "2.0.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "thread_local"version = "1.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"dependencies = [ "cfg-if",][[package]]name = "time"version = "0.3.47"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", "serde_core", "time-core", "time-macros",][[package]]name = "time-core"version = "0.1.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"[[package]]name = "time-macros"version = "0.2.27"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"dependencies = [ "num-conv", "time-core",][[package]]name = "tinystr"version = "0.8.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"dependencies = [ "displaydoc", "zerovec",][[package]]name = "tinyvec"version = "1.11.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"dependencies = [ "tinyvec_macros",][[package]]name = "tinyvec_macros"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"[[package]]name = "tokio"version = "1.52.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386"dependencies = [ "bytes", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2",][[package]]name = "tokio-macros"version = "2.7.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "tokio-rustls"version = "0.26.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"dependencies = [ "rustls", "tokio",][[package]]name = "tokio-stream"version = "0.1.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"dependencies = [ "futures-core", "pin-project-lite", "tokio",][[package]]name = "tokio-util"version = "0.7.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio",][[package]]name = "tower"version = "0.5.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"dependencies = [ "futures-core", "futures-util", "pin-project-lite", "sync_wrapper", "tokio", "tower-layer", "tower-service", "tracing",][[package]]name = "tower-cookies"version = "0.11.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"dependencies = [ "axum-core", "cookie", "futures-util", "http", "parking_lot", "pin-project-lite", "tower-layer", "tower-service",][[package]]name = "tower-http"version = "0.6.10"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"dependencies = [ "bitflags", "bytes", "futures-core", "futures-util", "http", "http-body", "http-body-util", "http-range-header", "httpdate", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", "tracing", "url",][[package]]name = "tower-layer"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"[[package]]name = "tower-service"version = "0.3.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"[[package]]name = "tracing"version = "0.1.44"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"dependencies = [ "log", "pin-project-lite", "tracing-attributes", "tracing-core",][[package]]name = "tracing-attributes"version = "0.1.31"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "tracing-core"version = "0.1.36"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"dependencies = [ "once_cell", "valuable",][[package]]name = "tracing-log"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"dependencies = [ "log", "once_cell", "tracing-core",][[package]]name = "tracing-subscriber"version = "0.3.23"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"dependencies = [ "matchers", "nu-ansi-term", "once_cell", "regex-automata", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log",][[package]]name = "try-lock"version = "0.2.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"[[package]]name = "typenum"version = "1.20.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"[[package]]name = "uaparser"version = "0.6.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a4c9e1c3f893758f154004195fc2d2c52fbda462df725220ceaef830ac29affa"dependencies = [ "derive_more", "lazy_static", "regex", "serde", "serde_derive", "serde_yaml",][[package]]name = "unicase"version = "2.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"[[package]]name = "unicode-bidi"version = "0.3.18"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"[[package]]name = "unicode-ident"version = "1.0.24"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"[[package]]name = "unicode-normalization"version = "0.1.25"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"dependencies = [ "tinyvec",][[package]]name = "unicode-properties"version = "0.1.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"[[package]]name = "unicode-xid"version = "0.2.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"[[package]]name = "unsafe-libyaml"version = "0.2.11"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"[[package]]name = "untrusted"version = "0.9.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"[[package]]name = "url"version = "2.5.8"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde",][[package]]name = "urlencoding"version = "2.1.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"[[package]]name = "utf8_iter"version = "1.0.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"[[package]]name = "uuid"version = "1.23.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"dependencies = [ "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen",][[package]]name = "valuable"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"[[package]]name = "vcpkg"version = "0.2.15"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"[[package]]name = "version_check"version = "0.9.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"[[package]]name = "want"version = "0.3.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"dependencies = [ "try-lock",][[package]]name = "wasi"version = "0.11.1+wasi-snapshot-preview1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"[[package]]name = "wasip2"version = "1.0.3+wasi-0.2.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"dependencies = [ "wit-bindgen 0.57.1",][[package]]name = "wasip3"version = "0.4.0+wasi-0.3.0-rc-2026-01-06"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"dependencies = [ "wit-bindgen 0.51.0",][[package]]name = "wasite"version = "0.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"[[package]]name = "wasm-bindgen"version = "0.2.120"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1"dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared",][[package]]name = "wasm-bindgen-futures"version = "0.4.70"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084"dependencies = [ "js-sys", "wasm-bindgen",][[package]]name = "wasm-bindgen-macro"version = "0.2.120"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103"dependencies = [ "quote", "wasm-bindgen-macro-support",][[package]]name = "wasm-bindgen-macro-support"version = "0.2.120"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41"dependencies = [ "bumpalo", "proc-macro2", "quote", "syn", "wasm-bindgen-shared",][[package]]name = "wasm-bindgen-shared"version = "0.2.120"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea"dependencies = [ "unicode-ident",][[package]]name = "wasm-encoder"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"dependencies = [ "leb128fmt", "wasmparser",][[package]]name = "wasm-metadata"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"dependencies = [ "anyhow", "indexmap", "wasm-encoder", "wasmparser",][[package]]name = "wasm-streams"version = "0.4.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"dependencies = [ "futures-util", "js-sys", "wasm-bindgen", "wasm-bindgen-futures", "web-sys",][[package]]name = "wasmparser"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"dependencies = [ "bitflags", "hashbrown 0.15.5", "indexmap", "semver",][[package]]name = "web-sys"version = "0.3.97"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602"dependencies = [ "js-sys", "wasm-bindgen",][[package]]name = "web-time"version = "1.1.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"dependencies = [ "js-sys", "wasm-bindgen",][[package]]name = "webpki-roots"version = "1.0.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"dependencies = [ "rustls-pki-types",][[package]]name = "whoami"version = "1.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"dependencies = [ "libredox", "wasite",][[package]]name = "windows-core"version = "0.62.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"dependencies = [ "windows-implement", "windows-interface", "windows-link", "windows-result", "windows-strings",][[package]]name = "windows-implement"version = "0.60.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "windows-interface"version = "0.59.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "windows-link"version = "0.2.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"[[package]]name = "windows-result"version = "0.4.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"dependencies = [ "windows-link",][[package]]name = "windows-strings"version = "0.5.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"dependencies = [ "windows-link",][[package]]name = "windows-sys"version = "0.48.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"dependencies = [ "windows-targets 0.48.5",][[package]]name = "windows-sys"version = "0.52.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"dependencies = [ "windows-targets 0.52.6",][[package]]name = "windows-sys"version = "0.60.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"dependencies = [ "windows-targets 0.53.5",][[package]]name = "windows-sys"version = "0.61.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"dependencies = [ "windows-link",][[package]]name = "windows-targets"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"dependencies = [ "windows_aarch64_gnullvm 0.48.5", "windows_aarch64_msvc 0.48.5", "windows_i686_gnu 0.48.5", "windows_i686_msvc 0.48.5", "windows_x86_64_gnu 0.48.5", "windows_x86_64_gnullvm 0.48.5", "windows_x86_64_msvc 0.48.5",][[package]]name = "windows-targets"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6",][[package]]name = "windows-targets"version = "0.53.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"dependencies = [ "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", "windows_x86_64_msvc 0.53.1",][[package]]name = "windows_aarch64_gnullvm"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"[[package]]name = "windows_aarch64_gnullvm"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"[[package]]name = "windows_aarch64_gnullvm"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"[[package]]name = "windows_aarch64_msvc"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"[[package]]name = "windows_aarch64_msvc"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"[[package]]name = "windows_aarch64_msvc"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"[[package]]name = "windows_i686_gnu"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"[[package]]name = "windows_i686_gnu"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"[[package]]name = "windows_i686_gnu"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"[[package]]name = "windows_i686_gnullvm"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"[[package]]name = "windows_i686_gnullvm"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"[[package]]name = "windows_i686_msvc"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"[[package]]name = "windows_i686_msvc"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"[[package]]name = "windows_i686_msvc"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"[[package]]name = "windows_x86_64_gnu"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"[[package]]name = "windows_x86_64_gnu"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"[[package]]name = "windows_x86_64_gnu"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"[[package]]name = "windows_x86_64_gnullvm"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"[[package]]name = "windows_x86_64_gnullvm"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"[[package]]name = "windows_x86_64_gnullvm"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"[[package]]name = "windows_x86_64_msvc"version = "0.48.5"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"[[package]]name = "windows_x86_64_msvc"version = "0.52.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"[[package]]name = "windows_x86_64_msvc"version = "0.53.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"[[package]]name = "wit-bindgen"version = "0.51.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"dependencies = [ "wit-bindgen-rust-macro",][[package]]name = "wit-bindgen"version = "0.57.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"[[package]]name = "wit-bindgen-core"version = "0.51.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"dependencies = [ "anyhow", "heck", "wit-parser",][[package]]name = "wit-bindgen-rust"version = "0.51.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"dependencies = [ "anyhow", "heck", "indexmap", "prettyplease", "syn", "wasm-metadata", "wit-bindgen-core", "wit-component",][[package]]name = "wit-bindgen-rust-macro"version = "0.51.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"dependencies = [ "anyhow", "prettyplease", "proc-macro2", "quote", "syn", "wit-bindgen-core", "wit-bindgen-rust",][[package]]name = "wit-component"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"dependencies = [ "anyhow", "bitflags", "indexmap", "log", "serde", "serde_derive", "serde_json", "wasm-encoder", "wasm-metadata", "wasmparser", "wit-parser",][[package]]name = "wit-parser"version = "0.244.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"dependencies = [ "anyhow", "id-arena", "indexmap", "log", "semver", "serde", "serde_derive", "serde_json", "unicode-xid", "wasmparser",][[package]]name = "writeable"version = "0.6.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"[[package]]name = "xattr"version = "1.6.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"dependencies = [ "libc", "rustix",][[package]]name = "yoke"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"dependencies = [ "stable_deref_trait", "yoke-derive", "zerofrom",][[package]]name = "yoke-derive"version = "0.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"dependencies = [ "proc-macro2", "quote", "syn", "synstructure",][[package]]name = "zerocopy"version = "0.8.48"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"dependencies = [ "zerocopy-derive",][[package]]name = "zerocopy-derive"version = "0.8.48"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "zerofrom"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"dependencies = [ "zerofrom-derive",][[package]]name = "zerofrom-derive"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"dependencies = [ "proc-macro2", "quote", "syn", "synstructure",][[package]]name = "zeroize"version = "1.8.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"[[package]]name = "zerotrie"version = "0.2.4"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"dependencies = [ "displaydoc", "yoke", "zerofrom",][[package]]name = "zerovec"version = "0.11.6"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"dependencies = [ "yoke", "zerofrom", "zerovec-derive",][[package]]name = "zerovec-derive"version = "0.11.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "zmij"version = "1.0.21"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
added Cargo.toml
@@ -0,0 +1,44 @@[package]name = "analytics"version = "0.1.0"edition = "2021"default-run = "analytics"[dependencies]axum = { version = "0.8", features = ["macros"] }tokio = { version = "1", features = ["full"] }tower = { version = "0.5", features = ["util"] }tower-http = { version = "0.6", features = ["fs", "cors", "trace", "set-header"] }tower-cookies = { version = "0.11", features = ["signed"] }minijinja = { version = "2", features = ["loader", "loop_controls", "json"] }serde = { version = "1", features = ["derive"] }serde_json = "1"serde_urlencoded = "0.7"sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono", "uuid"] }uuid = { version = "1", features = ["v4", "serde"] }chrono = { version = "0.4", features = ["serde"] }chrono-tz = "0.10"moka = { version = "0.12", features = ["future"] }maxminddb = "0.24"uaparser = "0.6"hmac = "0.12"sha2 = "0.10"base64 = "0.22"rand = "0.8"dotenvy = "0.15"anyhow = "1"thiserror = "2"tracing = "0.1"tracing-subscriber = { version = "0.3", features = ["env-filter"] }reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }flate2 = "1"tar = "0.4"tempfile = "3"mime_guess = "2"urlencoding = "2"futures-util = "0.3"[profile.release]lto = truecodegen-units = 1strip = true
modified Dockerfile
@@ -1,31 +1,45 @@FROM alpine:3.21# syntax=docker/dockerfile:1# ----- builder -----FROM rust:alpine AS builderENV LANG="C.UTF-8" \    PYTHONUNBUFFERED=1RUN apk add --no-cache musl-dev pkgconfig openssl-devCOPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/COPY --from=oven/bun:alpine /usr/local/bin/bun /usr/local/bin/bunRUN apk add --update --no-cache \      python3 py3-pip \      chromium libstdc++ nss harfbuzz freetype \      font-noto font-noto-extra font-noto-emojiWORKDIR /appCOPY pyproject.toml uv.lock package.json bun.lock /app/RUN bun install --frozen-lockfile && \    uv sync --frozen --no-devCOPY Cargo.toml Cargo.lock ./COPY src ./srcCOPY migrations ./migrationsCOPY frontend ./frontendRUN cd frontend && bun install --frozen-lockfile && bun run build && bun run build:mapsCOPY . .RUN --mount=type=cache,target=/usr/local/cargo/registry \    --mount=type=cache,target=/app/target \    cargo build --release && \    cp target/release/analytics /app/analyticsENV PATH="/app/.venv/bin:/app/node_modules/.bin:$PATH"# ----- runtime -----FROM alpine:3.23RUN bun run build && \    bun run build:maps && \    uv run python manage.py collectstatic --noinputRUN apk add --no-cache chromium font-jetbrains-mono ttf-dejavuWORKDIR /appCOPY --from=builder /app/analytics ./analyticsCOPY --from=builder /app/dist ./distCOPY --from=builder /app/static_maps ./static_mapsCOPY templates ./templatesCOPY migrations ./migrationsRUN addgroup -S -g 1000 app && \    adduser -S -h /app -s /sbin/nologin -u 1000 -G app app && \    chown -R app:app /appUSER app:app    mkdir -p /data && chown -R app:app /app /dataUSER appENV PORT=8000ENV ANALYTICS_DATA_DIR=/dataEXPOSE 8000CMD ["./analytics"]
modified Makefile
@@ -1,70 +1,56 @@# Django + Vite Makefile# v. 2022.07.13CARGO ?= $(HOME)/.cargo/bin/cargoPORT  ?= 8000.DEFAULT_GOAL := run.PHONY: run build start clean push pull maps seed migrate.PHONY: run runserver vite clean push pull update.DEFAULT: run# Dev: Vite watch + cargo run concurrently. Both die on Ctrl+C.run: frontend/node_modules dist/.vite/manifest.json	@trap 'kill 0' EXIT INT TERM; \	(cd frontend && bun run dev) & \	PORT=$(PORT) $(CARGO) run# Production build (Vite assets + release binary)build: frontend/node_modules maps	cd frontend && bun run build	$(CARGO) build --releaseSERVER_URL = $(shell git config --get remote.server.url | sed 's|ssh://||' | cut -d ':' -f 1 | cut -d '/' -f 1)PROJECT_NAME = $(shell basename $(PWD))# Run the release binary (after `make build`)start:	PORT=$(PORT) ./target/release/analytics# Build the per-country topojson static_maps/ from natural earthmaps: frontend/node_modules	cd frontend && bun run build:mapsrun: install	@echo "run ----------------------------------------------------------------"	${MAKE} -j2 runserver viterunserver:	uv run python manage.py runserver 0.0.0.0:8000vite:	bun run devinstall: node_modules/touchfile .venv/touchfile db.sqlite3node_modules/touchfile: package.json	@echo "install node deps --------------------------------------------------"	bun install	touch $@	@echo "> all node deps installed".venv/touchfile: pyproject.toml	@echo "install python deps ------------------------------------------------"	uv sync	touch $@	@echo "> all python deps installed"db.sqlite3:	@echo "create database ----------------------------------------------------"	uv run python manage.py migrate	@echo "> database created"clean:	$(CARGO) clean	rm -rf dist frontend/node_modules data/db.sqlite3 data/db.mmdbpush:	@echo "push ---------------------------------------------------------------"	git remote | xargs -I R git push R masterpull:	@echo "pull ---------------------------------------------------------------"	rsync -avz $(SERVER_URL):/srv/data/$(PROJECT_NAME)/db/db.sqlite3 db.sqlite3	rsync -avz $(SERVER_URL):/srv/data/$(PROJECT_NAME)/db.mmdb db.mmdb	rsync -avz $(SERVER_URL):/srv/data/$(PROJECT_NAME)/media/ media	@echo "> all files copied"# Seed a "Seed Test" property with realistic fake events. Re-runnable.# Override defaults: `make seed SESSIONS=2000 DAYS=60`seed:	$(CARGO) run --bin seed -- $(SESSIONS) $(DAYS)# Import an existing Django analytics SQLite into the rust hot-field schema.# `make migrate FROM=../analytics/db.sqlite3` (add FORCE=1 to wipe first).migrate:	@if [ -z "$(FROM)" ]; then echo "usage: make migrate FROM=<path-to-django.sqlite3> [FORCE=1]"; exit 2; fi	$(CARGO) run -- migrate "$(FROM)" $(if $(FORCE),--force,)update: install	@echo "update -------------------------------------------------------------"	uv lock --upgrade	bun update --latest	@echo "> all deps updated"# Pull production data (db, geoip, media) for local devpull:	@SERVER=$$(git config --get remote.server.url | sed 's|ssh://||' | cut -d ':' -f 1 | cut -d '/' -f 1); \	NAME=$$(basename $$(pwd)); \	mkdir -p data; \	rsync -avz $$SERVER:/srv/data/$$NAME/db/db.sqlite3 data/db.sqlite3; \	rsync -avz $$SERVER:/srv/data/$$NAME/db.mmdb data/db.mmdbfrontend/node_modules:	cd frontend && bun installclean:	@echo "clean --------------------------------------------------------------"	rm -rf node_modules	rm -rf .venv	rm -rf db.sqlite3	rm -rf db.mmdb	rm -rf media	@echo "> all files removed"dist/.vite/manifest.json: frontend/node_modules	cd frontend && bun run build
modified README.md
@@ -1,196 +1,22 @@# AnalyticsA self-hostable analytics service with a straightforward API to collect eventsfrom any source.Self-hosted website analytics, single-operator. Single-binary axum app with sqlx + SQLite, minijinja templates, and a Vite + Bootstrap frontend.## Quickstart## MotivationI was bored and felt like writing my own analytics service over the weekend.## Features- Standard website analytics collection- Custom metrics collection- UTM query collection- Optional anonymized location collection- Customizable UI- Date range selection and comparison- Public URL sharing- Customizable## RequirementsYou need docker + docker-compose installed for a quick production start or youcan figure out how we install and run things via the `Dockerfile` and set it upyourself.If you want to install things without docker then you'll need the followingdependencies:- python- uv- bun- chromium (used for server-side PDF report generation via a subprocess wrapper)You can also check the `Dockerfile` for an exact list of dependencies and adjustpackage names for your desired platform.This is a standard Django project. If you know how to run Django, or want tolook up any Django tutorial on how to run Django, you shouldn't have a problemgetting this project running on almost anything.## Running locallyIf you have all of the above dependencies installed you can use my Makefile torun and install python and node dependencies locally. Running `make` will checkthat you have the proper dependencies installed and if not it will try andinstall them for you. It will then create you a fresh database and runeverything.## Checking outdated dependenciesThis can be done in both bun and uv with the following two commands:    uv lock --upgrade --dry-run    bun outdatedYou can then upgrade all dependencies at once with:    make updateI recommend testing everything after this to make sure it's all working.## Optimizing images with webpMy development system runs Ubuntu so I installed the official webp utils fromGoogle with `apt install webp`.    cwebp -q 90 -m 6 -o output.webp input.png## Using docker-composeThe easiest way to run this project is to run it using`docker-compose up --build -d` if you have `docker-compose` and `docker`installed. This will start the server and have you running at port 8000. Thefirst time you do this make sure you run migrations with`docker-compose run web python manage.py migrate`. Make sure you setup the`.env` file before running, you can copy the sample from`samplefiles/env.sample` into the root of the project as `.env` and change thevariables.## Default userThe default user is `admin` with the password `admin`. We also create an exampleproperty so you can see how the analytics look and a property to collect metricsfrom ourselves.## User location dataI'm not interested in someone's personal location but I do like to know wherepeople are coming from region wise. This helps me know if I need to addtranslations to my projects or if I need to add a CDN/caching/server to a newregion. We don't store user IPs so location data isn't retroactive.The dashboard ships with a `refresh_geoip` management command that downloadsthe [DB-IP City Lite](https://db-ip.com/db/download/ip-to-city-lite) database(CC-BY-4.0, no signup, MaxMind-compatible MMDB format) into `GEOIP_PATH`. Itruns automatically on container start; if the file already exists and is lessthan 30 days old it skips the download.To keep it fresh, add a host cron entry on the server that re-runs it monthly:```cron# /etc/crontabs/root  — refresh GeoIP on the 4th of each month0 3 4 * * docker exec analytics_web python manage.py refresh_geoip --force```shcp samplefiles/env.sample .env# edit .env to set ANALYTICS_PASSWORD, BASE_URL, etc.make```The 4th gives DB-IP a few days to publish their monthly build (1st of themonth). Failures are non-fatal — the collector silently skips GeoIPenrichment if the database is missing.If you'd rather use a different MMDB (MaxMind GeoLite2, IPLocate, etc.) justdrop it at `GEOIP_PATH` (defaults to `/data/db.mmdb` in production) anddisable the cron — any MaxMind-format database works.## BackupsAll data is stored in `/srv/data/analytics/` and your repo is stored in`/srv/git/analytics.git/`. You can backup both of these folders and you'll havea 100% backup of everything except changes you may have made to the `Caddyfile`and the `.env` file which should be easy enough to recreate but you can backthose up too!## Server guideThis quickstart requires that you have an Alpine Linux server running with adomain name pointed to it. I'm currently using Linode as my host since theysupport Alpine Linux nicely. If you don't want to use Linode or Alpine Linuxyou can use these instructions and just change the apk commands at the start towhatever Linux distro you're using.**IMPORTANT NOTE**: Change `analytics.bythewood.me` to your domain name whererelevant in these instructions.**TIP**: During the ufw portion to enable the firewall I recommend only allowingyour IP address or your ISP's IP address range which you can find on whoislookups at the top. For example, replace `192.230.176.0/20` with your IP or yourISP's IP range.    ufw allow from 192.230.176.0/20 proto tcp to any port 22I allow my local ISP's range because I have a DHCP lease from them and I gettired of logging into my server from my hosting provider's UI to update it. It'sgood enough security and much better than nothing!Server:    apk update && apk upgrade && apk add docker docker-compose caddy git iptables ip6tables ufw    ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw --force enable    echo -e "#!/bin/sh\napk upgrade --update | sed \"s/^/[\`date\`] /\" >> /var/log/apk-autoupgrade.log" > /etc/periodic/daily/apk-autoupgrade && chmod 700 /etc/periodic/daily/apk-autoupgrade    rc-update add docker boot && service docker start    mkdir -p /srv/git/analytics.git && cd /srv/git/analytics.git && git init --bareLocal:    git clone git@github.com:overshard/analytics.git && cd analytics    git remote remove origin && git remote add origin root@analytics.bythewood.me:/srv/git/analytics.git    git push --set-upstream origin masterServer:    mkdir -p /srv/docker && cd /srv/docker && git clone /srv/git/analytics.git analytics && cd /srv/docker/analytics    cp samplefiles/Caddyfile.sample /etc/caddy/Caddyfile && sed -i 's/analytics.example.com/analytics.bythewood.me/g' /etc/caddy/Caddyfile    cp samplefiles/env.sample .env && sed -i 's/analytics.example.com/analytics.bythewood.me/g' .env    cp samplefiles/post-receive.sample /srv/git/analytics.git/hooks/post-receive    mkdir -p /srv/data/analytics/db && chown -R 1000:1000 /srv/data/analytics    docker-compose up --build --detach && docker-compose run web python3 manage.py migrate --noinput && docker-compose run web sqlite3 db.sqlite3 "PRAGMA journal_mode=WAL;" ".exit"    rc-update add caddy boot && service caddy start## ScalingI choose to use an sqlite3 database since that handles all my usecases justfine. My first recommendation for scaling this project would be to use aPostgreSQL database. If you want to get fancy then a time-series database likeTimescale would make a lot of sense. The foundation of this project is pureDjango so it shouldn't be hard to swap in a different database.Then visit http://localhost:8000/login.## Stack## Support- axum + sqlx (sqlite, WAL) on the backend, single binary- minijinja templates, Vite + Bootstrap 5 frontend, Bun for JS deps- maxminddb for GeoIP, ua-parser for user agents, moka for the dashboard cache- chrome-headless-shell for PDF report exportI won't be providing any user support for this project. I'm more than happy toaccept good pull requests and fix bugs but I don't have the time to help peoplerun or use this project. I appologize in advance for this. Maintainingmutliple OSS projects has taught me that I need to step back from trying toprovide support to avoid burnout.See [CLAUDE.md](CLAUDE.md) for the full architecture rundown.
deleted TODO.md
@@ -1,34 +0,0 @@# TODO## Upgrade SQLite to ≥3.51.3 to close the WAL-reset corruption bugThe `status` repo hit a `database disk image is malformed` corruption on2026-04-19. Root cause was a two-part interaction:1. **SQLite WAL-reset bug** (introduced 3.7.0, fixed in **3.51.3** released   2026-03-13). Triggered when two or more connections on the same file   write/checkpoint simultaneously.2. **`PRAGMA mmap_size=128MB`** amplified a transient WAL inconsistency into   structural corruption. SQLite docs explicitly warn against mmap with   multi-process writers.This repo had the same `mmap_size` PRAGMA and the same Alpine 3.21 base, sommap has been removed defensively (commit `9cca9dc`). Risk is lower thanstatus because there's no always-on thread-pool scheduler — just gunicorn's2 workers — but the latent bug still exists because we're stuck on SQLite3.48.0 (Alpine 3.21).### Path forward, by preference- **Wait for Alpine 3.23** to ship SQLite ≥3.51.3 (likely May–June 2026), then  bump `FROM alpine:3.21` in the Dockerfile. Zero code change.- If it recurs before Alpine 3.23 lands: build SQLite from source in the  Dockerfile and `LD_PRELOAD` it, or switch the DB to Postgres.### Versions checked 2026-04-26- Alpine 3.21: sqlite-libs 3.48.0 (vulnerable, currently in use)- Alpine 3.22: sqlite-libs 3.49.2 (vulnerable)- Alpine edge: sqlite-libs 3.53.0 (fixed, but edge isn't appropriate for prod)- `pysqlite3-binary` 0.5.4.post2: bundles 3.51.1 (vulnerable; package's last  release predates the SQLite fix)
deleted accounts/__init__.py
deleted accounts/admin.py
@@ -1,11 +0,0 @@from django.contrib import adminfrom django.contrib.auth.models import Groupfrom django.contrib.auth.admin import UserAdminfrom .models import Useradmin.site.register(User, UserAdmin)admin.site.unregister(Group)
deleted accounts/apps.py
@@ -1,6 +0,0 @@from django.apps import AppConfigclass AccountsConfig(AppConfig):    default_auto_field = 'django.db.models.BigAutoField'    name = 'accounts'
deleted accounts/forms.py
@@ -1,9 +0,0 @@from django import formsfrom .models import Userclass UserForm(forms.ModelForm):    class Meta:        model = User        fields = ('username', 'email',)
deleted accounts/migrations/0001_initial.py
@@ -1,45 +0,0 @@# Generated by Django 4.0.4 on 2022-05-14 18:00import django.contrib.auth.modelsimport django.contrib.auth.validatorsfrom django.db import migrations, modelsimport django.utils.timezoneimport uuidclass Migration(migrations.Migration):    initial = True    dependencies = [        ('auth', '0012_alter_user_first_name_max_length'),    ]    operations = [        migrations.CreateModel(            name='User',            fields=[                ('password', models.CharField(max_length=128, verbose_name='password')),                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),                ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),                ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),                ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),                ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),                ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),                ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),                ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),                ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),                ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),            ],            options={                'verbose_name': 'user',                'verbose_name_plural': 'users',                'abstract': False,            },            managers=[                ('objects', django.contrib.auth.models.UserManager()),            ],        ),    ]
deleted accounts/migrations/0002_initial_data.py
@@ -1,21 +0,0 @@from django.db import migrationsdef create_superuser(apps, schema_editor):    """    Makes the initial admin superuser with the username admin and the password    admin.    """    User = apps.get_model('accounts', 'User')    User.objects.create_superuser(username='admin', password='admin')class Migration(migrations.Migration):    dependencies = [        ('accounts', '0001_initial'),    ]    operations = [        migrations.RunPython(create_superuser),    ]
deleted accounts/migrations/__init__.py
deleted accounts/models.py
@@ -1,39 +0,0 @@import uuidfrom django.db import modelsfrom django.db.models import Count, Qfrom django.contrib.auth.models import AbstractUserclass User(AbstractUser):    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)    def __str__(self):        return self.username    def _event_totals(self):        # One query rolls up everything the profile page displays.        if not hasattr(self, "_cached_event_totals"):            self._cached_event_totals = self.properties.aggregate(                total_properties=Count("id", distinct=True),                total_events=Count("events"),                total_page_views=Count("events", filter=Q(events__event="page_view")),                total_session_starts=Count("events", filter=Q(events__event="session_start")),            )        return self._cached_event_totals    @property    def total_properties(self):        return self._event_totals()["total_properties"]    @property    def total_events(self):        return self._event_totals()["total_events"]    @property    def total_page_views(self):        return self._event_totals()["total_page_views"]    @property    def total_session_starts(self):        return self._event_totals()["total_session_starts"]
deleted accounts/static_src/index.js
deleted accounts/templates/accounts/profile.html
@@ -1,40 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">operator · {{ request.user.username }}</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">Account identifiers. Changes save on submit.</p>      <form method="POST">        {% csrf_token %}        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" name="username" id="id_username" placeholder="Username" value="{{ form.username.value }}" />          <label for="id_username">Username</label>        </div>        <div class="form-floating mb-3">          <input type="email" class="form-control {% if form.email.errors %}is-invalid{% endif %}" name="email" id="id_email" placeholder="Email" value="{{ form.email.value }}" />          <label for="id_email">Email</label>        </div>        <div class="d-flex gap-2">          <button type="submit" class="btn btn-primary">Save →</button>          <a href="{% url 'password_change' %}" class="btn btn-outline-light">Change password</a>        </div>      </form>    </div>  </div></div>{% endblock %}
deleted accounts/urls.py
@@ -1,59 +0,0 @@from django.contrib.auth import views as auth_viewsfrom django.urls import pathfrom . import viewsurlpatterns = [    path(        "login/",        auth_views.LoginView.as_view(extra_context={"title": "Login"}),        name="login",    ),    path(        "logout/",        auth_views.LogoutView.as_view(extra_context={"title": "Logout"}),        name="logout",    ),    path(        "password-change/",        auth_views.PasswordChangeView.as_view(            extra_context={"title": "Password change"}        ),        name="password_change",    ),    path(        "password_change/done/",        auth_views.PasswordChangeDoneView.as_view(            extra_context={"title": "Password change done"}        ),        name="password_change_done",    ),    path(        "password-reset/",        auth_views.PasswordResetView.as_view(extra_context={"title": "Password reset"}),        name="password_reset",    ),    path(        "password-reset/done/",        auth_views.PasswordResetDoneView.as_view(            extra_context={"title": "Password reset done"}        ),        name="password_reset_done",    ),    path(        "reset/<uidb64>/<token>/",        auth_views.PasswordResetConfirmView.as_view(            extra_context={"title": "Password reset confirm"}        ),        name="password_reset_confirm",    ),    path(        "reset/done/",        auth_views.PasswordResetCompleteView.as_view(            extra_context={"title": "Password reset complete"}        ),        name="password_reset_complete",    ),    path("profile/", views.profile, name="profile"),]
deleted accounts/views.py
@@ -1,19 +0,0 @@from django.shortcuts import renderfrom django.contrib import messagesfrom .forms import UserFormdef profile(request):    if request.method == "POST":        form = UserForm(request.POST, instance=request.user)        if form.is_valid():            form.save()            messages.success(request, "Your profile was successfully updated!")    else:        form = UserForm(instance=request.user)    return render(        request,        "accounts/profile.html",        {"form": form, "title": "Profile", "description": "Update your profile."},    )
deleted analytics/__init__.py
deleted analytics/asgi.py
@@ -1,16 +0,0 @@"""ASGI config for analytics project.It exposes the ASGI callable as a module-level variable named ``application``.For more information on this file, seehttps://docs.djangoproject.com/en/4.0/howto/deployment/asgi/"""import osfrom django.core.asgi import get_asgi_applicationos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'analytics.settings.development')application = get_asgi_application()
deleted analytics/chromium.py
@@ -1,174 +0,0 @@"""Headless Chromium wrapper for generating screenshots and PDFs from URLs or HTML.Shells out to the system chromium binary (no Playwright, no Selenium, no bundledbrowser). Works on Alpine, Ubuntu, and macOS as long as a chromium-family binaryis discoverable on PATH."""from __future__ import annotationsimport osimport shutilimport subprocessimport tempfilefrom contextlib import contextmanagerfrom pathlib import Pathfrom typing import Iterator, Optional, Tuplefrom django.core.files import Filefrom django.core.files.storage import default_storageDEFAULT_VIEWPORT: Tuple[int, int] = (1280, 720)DEFAULT_VIRTUAL_TIME_BUDGET_MS = 5_000DEFAULT_SUBPROCESS_TIMEOUT_S = 60BASE_FLAGS = (    "--headless=new",    "--no-sandbox",    "--no-zygote",    "--disable-gpu",    "--disable-dev-shm-usage",    "--disable-software-rasterizer",    "--disable-extensions",    "--disable-background-networking",    "--disable-crash-reporter",    "--disable-logging",    "--hide-scrollbars",)def _find_chromium() -> Optional[str]:    for binary in ("chromium", "chromium-browser", "google-chrome"):        path = shutil.which(binary)        if path:            return path    return Noneclass ChromiumError(RuntimeError):    pass@contextmanagerdef _tempfile(suffix: str) -> Iterator[Path]:    fd, raw = tempfile.mkstemp(suffix=suffix, dir="/tmp")    os.close(fd)    path = Path(raw)    try:        yield path    finally:        path.unlink(missing_ok=True)@contextmanagerdef _html_tempfile(html: str) -> Iterator[str]:    fd, raw = tempfile.mkstemp(suffix=".html", dir="/tmp")    path = Path(raw)    try:        with os.fdopen(fd, "w", encoding="utf-8") as fp:            fp.write(html)        yield f"file://{path}"    finally:        path.unlink(missing_ok=True)def _run(args: list[str], timeout: int) -> None:    binary = _find_chromium()    if not binary:        raise ChromiumError(            "No chromium binary found on PATH (tried: chromium, "            "chromium-browser, google-chrome)"        )    cmd = [binary, *BASE_FLAGS, *args]    try:        subprocess.run(cmd, check=True, capture_output=True, timeout=timeout)    except subprocess.TimeoutExpired as exc:        raise ChromiumError(f"chromium timed out after {timeout}s") from exc    except subprocess.CalledProcessError as exc:        stderr = (exc.stderr or b"").decode("utf-8", errors="replace").strip()        raise ChromiumError(            f"chromium exited {exc.returncode}: {stderr or '(no stderr)'}"        ) from excdef _save(source: Path, filename: str) -> str:    if default_storage.exists(filename):        default_storage.delete(filename)    with source.open("rb") as fp:        default_storage.save(filename, File(fp))    return default_storage.url(filename)def generate_screenshot_from_url(    url: str,    filename: str,    *,    viewport: Tuple[int, int] = DEFAULT_VIEWPORT,    virtual_time_budget_ms: int = DEFAULT_VIRTUAL_TIME_BUDGET_MS,    timeout: int = DEFAULT_SUBPROCESS_TIMEOUT_S,) -> str:    with _tempfile(".png") as out:        _run(            [                f"--screenshot={out}",                f"--window-size={viewport[0]},{viewport[1]}",                f"--virtual-time-budget={virtual_time_budget_ms}",                url,            ],            timeout,        )        return _save(out, filename)def generate_screenshot_from_html(    html: str,    filename: str,    *,    viewport: Tuple[int, int] = DEFAULT_VIEWPORT,    virtual_time_budget_ms: int = DEFAULT_VIRTUAL_TIME_BUDGET_MS,    timeout: int = DEFAULT_SUBPROCESS_TIMEOUT_S,) -> str:    with _html_tempfile(html) as url:        return generate_screenshot_from_url(            url,            filename,            viewport=viewport,            virtual_time_budget_ms=virtual_time_budget_ms,            timeout=timeout,        )def generate_pdf_from_url(    url: str,    filename: str,    *,    virtual_time_budget_ms: int = DEFAULT_VIRTUAL_TIME_BUDGET_MS,    timeout: int = DEFAULT_SUBPROCESS_TIMEOUT_S,) -> str:    with _tempfile(".pdf") as out:        _run(            [                f"--print-to-pdf={out}",                "--no-pdf-header-footer",                f"--virtual-time-budget={virtual_time_budget_ms}",                url,            ],            timeout,        )        return _save(out, filename)def generate_pdf_from_html(    html: str,    filename: str,    *,    virtual_time_budget_ms: int = DEFAULT_VIRTUAL_TIME_BUDGET_MS,    timeout: int = DEFAULT_SUBPROCESS_TIMEOUT_S,) -> str:    with _html_tempfile(html) as url:        return generate_pdf_from_url(            url,            filename,            virtual_time_budget_ms=virtual_time_budget_ms,            timeout=timeout,        )
deleted analytics/context_processors.py
@@ -1,29 +0,0 @@from django.conf import settingsfrom properties.models import Propertydef collector(request):    """    Gets the id of our "Proprium" property and sets it as a context variable    to be used to collect metrics from ourselves.    """    try:        prop = Property.objects.get(name='Proprium') or Property.objects.get(name='analytics.bythewood.me')        return {'collector_server': settings.BASE_URL, 'collector_id': prop.id}    except Property.DoesNotExist:        return {}def canonical(request):    """    Gets the canonical URL for the current request.    """    return {'canonical': request.build_absolute_uri(request.path)}def base_url(request):    """    Provides the BASE_URL from settings.    """    return {'BASE_URL': settings.BASE_URL}
deleted analytics/mailer.py
@@ -1,53 +0,0 @@import loggingimport smtplibfrom concurrent.futures import ThreadPoolExecutorimport dns.resolverfrom django.core.mail.backends.base import BaseEmailBackendlog = logging.getLogger(__name__)_POOL = ThreadPoolExecutor(max_workers=4, thread_name_prefix="mailer")def _deliver(message):    try:        by_domain = {}        for rcpt in message.to + message.cc + message.bcc:            by_domain.setdefault(rcpt.rsplit("@", 1)[1], []).append(rcpt)        payload = message.message().as_bytes()        sender = message.from_email        for domain, rcpts in by_domain.items():            mxs = sorted(                dns.resolver.resolve(domain, "MX"),                key=lambda r: r.preference,            )            for mx in mxs:                host = str(mx.exchange).rstrip(".")                try:                    with smtplib.SMTP(                        host, 25, local_hostname="bythewood.me", timeout=30                    ) as smtp:                        smtp.ehlo()                        try:                            smtp.starttls()                            smtp.ehlo()                        except smtplib.SMTPNotSupportedError:                            pass                        smtp.sendmail(sender, rcpts, payload)                    break                except Exception as exc:                    log.warning("MX %s failed for %s: %s", host, domain, exc)            else:                log.error("all MX hosts failed for %s", domain)    except Exception:        log.exception("mail delivery failed")class DirectMXBackend(BaseEmailBackend):    def send_messages(self, email_messages):        for message in email_messages:            _POOL.submit(_deliver, message)        return len(email_messages)
deleted analytics/settings/__init__.py
@@ -1,150 +0,0 @@import osfrom pathlib import Pathfrom django.contrib.messages import constants as messages# Build paths inside the project like this: BASE_DIR / 'subdir'.BASE_DIR = Path(__file__).resolve().parent.parent.parent# Application definitionINSTALLED_APPS = [    'django.contrib.admin',    'django.contrib.auth',    'django.contrib.contenttypes',    'django.contrib.sessions',    'django.contrib.messages',    'django.contrib.staticfiles',    'accounts',    'properties',    'collector',    'pages',]MIDDLEWARE = [    'django.middleware.security.SecurityMiddleware',    'whitenoise.middleware.WhiteNoiseMiddleware',    'django.contrib.sessions.middleware.SessionMiddleware',    'django.middleware.common.CommonMiddleware',    'django.middleware.csrf.CsrfViewMiddleware',    'django.contrib.auth.middleware.AuthenticationMiddleware',    'django.contrib.messages.middleware.MessageMiddleware',    'django.middleware.clickjacking.XFrameOptionsMiddleware',]ROOT_URLCONF = 'analytics.urls'TEMPLATES = [    {        'BACKEND': 'django.template.backends.django.DjangoTemplates',        'DIRS': [BASE_DIR / 'analytics/templates'],        'APP_DIRS': True,        'OPTIONS': {            'context_processors': [                'django.template.context_processors.debug',                'django.template.context_processors.request',                'django.contrib.auth.context_processors.auth',                'django.contrib.messages.context_processors.messages',                'analytics.context_processors.collector',                'analytics.context_processors.canonical',                'analytics.context_processors.base_url',            ],        },    },]WSGI_APPLICATION = 'analytics.wsgi.application'# Messages# https://docs.djangoproject.com/en/3.0/ref/settings/#messagesMESSAGE_TAGS = {    messages.DEBUG: "alert-info",    messages.INFO: "alert-info",    messages.SUCCESS: "alert-success",    messages.WARNING: "alert-warning",    messages.ERROR: "alert-danger",}# Internationalization# https://docs.djangoproject.com/en/4.0/topics/i18n/LANGUAGE_CODE = 'en-us'TIME_ZONE = 'UTC'USE_I18N = TrueUSE_TZ = TrueUSE_THOUSAND_SEPARATOR = True# Static files (CSS, JavaScript, Images)# https://docs.djangoproject.com/en/4.0/howto/static-files/STATIC_URL = 'static/'STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"STATICFILES_DIRS = (    BASE_DIR / "analytics/static",    BASE_DIR / "analytics/static_maps",)STATIC_ROOT = BASE_DIR / "static"# Media files (Images, Videos)# https://docs.djangoproject.com/en/4.0/ref/settings/#media-rootMEDIA_URL = 'media/'MEDIA_ROOT = BASE_DIR / "media"# Database# https://docs.djangoproject.com/en/4.0/ref/settings/#databasesDATABASES = {    'default': {        'ENGINE': 'django.db.backends.sqlite3',        'NAME': BASE_DIR / 'db.sqlite3',        'OPTIONS': {            'timeout': 30,            'transaction_mode': 'IMMEDIATE',            # mmap_size is intentionally omitted: gunicorn workers each open            # their own connection, and SQLite's mmap is documented as unsafe            # for multi-process writers — it amplified an unrelated WAL            # inconsistency into full database corruption in the status repo            # on 2026-04-19.            'init_command': (                'PRAGMA journal_mode=WAL;'                'PRAGMA synchronous=NORMAL;'                'PRAGMA foreign_keys=ON;'                'PRAGMA temp_store=MEMORY;'                'PRAGMA journal_size_limit=67108864;'                'PRAGMA cache_size=-20000;'            ),        },    }}# Default primary key field type# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-fieldDEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'# Auth# https://docs.djangoproject.com/en/3.0/ref/settings/#authAUTH_USER_MODEL = "accounts.User"LOGIN_REDIRECT_URL = "properties"# Email# https://docs.djangoproject.com/en/4.0/topics/email/#console-backendEMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
deleted analytics/settings/development.py
@@ -1,39 +0,0 @@from . import *  # noqa# Custom settingsBASE_URL = "http://localhost:8000"# Core settings# https://docs.djangoproject.com/en/4.0/ref/settings/#core-settingsSECRET_KEY = 'django-insecure-kbp9@ye$bf)$^l5-6q_4&2*ghbh29s*u)s2fy05xzj6gorvv!t'# Debuggging# https://docs.djangoproject.com/en/4.0/ref/settings/#debuggingDEBUG = True# Security# https://docs.djangoproject.com/en/4.0/ref/settings/#securityCSRF_TRUSTED_ORIGINS = [    'http://localhost:3000',    'http://localhost:8000',]# Media files (Images, Videos)# https://docs.djangoproject.com/en/4.0/ref/settings/#media-rootMEDIA_URL = BASE_URL + '/media/'# GeoIP2# https://docs.djangoproject.com/en/4.0/ref/contrib/gis/geoip2/#std-setting-GEOIP_PATHGEOIP_PATH = BASE_DIR / 'db.mmdb'
deleted analytics/settings/production.py
@@ -1,86 +0,0 @@import osfrom . import *  # noqa# Custom settingsBASE_URL = os.environ.get("DJANGO_BASE_URL")# Core settings# https://docs.djangoproject.com/en/4.0/ref/settings/#core-settingsALLOWED_HOSTS = [os.environ.get("DJANGO_BASE_URL").split("//")[1]]# Debuggging# https://docs.djangoproject.com/en/4.0/ref/settings/#debuggingDEBUG = False# HTTP# https://docs.djangoproject.com/en/4.0/ref/settings/#httpSECURE_BROWSER_XSS_FILTER = TrueSECURE_HSTS_INCLUDE_SUBDOMAINS = TrueSECURE_HSTS_PRELOAD = TrueSECURE_HSTS_SECONDS = 31536000SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")SECURE_SSL_REDIRECT = TrueUSE_X_FORWARDED_HOST = True# Security# https://docs.djangoproject.com/en/4.0/ref/settings/#securityCSRF_COOKIE_SECURE = TrueCSRF_TRUSTED_ORIGINS = [os.environ.get("DJANGO_BASE_URL")]SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")SESSION_COOKIE_SECURE = True# Database# https://docs.djangoproject.com/en/4.0/ref/settings/#databasesDATABASES["default"]["NAME"] = "/data/db/db.sqlite3"# Media files (Images, Videos)# https://docs.djangoproject.com/en/4.0/ref/settings/#media-rootMEDIA_URL = BASE_URL + "/media/"MEDIA_ROOT = "/data/media"# Email# https://docs.djangoproject.com/en/4.0/topics/email/#email-backendsEMAIL_BACKEND = "analytics.mailer.DirectMXBackend"DEFAULT_FROM_EMAIL = "noreply@bythewood.me"SERVER_EMAIL = "noreply@bythewood.me"# Logging# https://docs.djangoproject.com/en/4.0/topics/logging/LOGGING = {    "version": 1,    "disable_existing_loggers": False,    "handlers": {        "console": {            "class": "logging.StreamHandler",        },    },    "root": {        "handlers": ["console"],        "level": "WARNING",    },}# GeoIP2# https://docs.djangoproject.com/en/4.0/ref/contrib/gis/geoip2/#std-setting-GEOIP_PATHGEOIP_PATH = "/data/db.mmdb"
deleted analytics/templates/403.html
@@ -1,23 +0,0 @@{% extends 'base.html' %}{% load static %}{% block title %}403{% endblock %}{% block description %}You are not allowed to access this page.{% endblock %}{% block breadcrumbs %}{% endblock %}{% block main %}<div class="container">  <div class="row">    <div class="col text-center py-5 my-5">      <div class="section-label mb-3" style="justify-content:center;">forbidden</div>      <h1 class="display-1 text-white fw-bolder mb-3" style="letter-spacing: -0.02em;">403</h1>      <p class="text-muted">You are not allowed to access this page.</p>      <a href="/" class="btn btn-outline-light mt-3">Home →</a>    </div>  </div></div>{% endblock %}
deleted analytics/templates/404.html
@@ -1,23 +0,0 @@{% extends 'base.html' %}{% load static %}{% block title %}404{% endblock %}{% block description %}The page you are looking for doesn't exist.{% endblock %}{% block breadcrumbs %}{% endblock %}{% block main %}<div class="container">  <div class="row">    <div class="col text-center py-5 my-5">      <div class="section-label mb-3" style="justify-content:center;">not found</div>      <h1 class="display-1 text-white fw-bolder mb-3" style="letter-spacing: -0.02em;">404</h1>      <p class="text-muted">The page you're looking for doesn't exist — or was never routed.</p>      <a href="/" class="btn btn-outline-light mt-3">Home →</a>    </div>  </div></div>{% endblock %}
deleted analytics/templates/500.html
@@ -1,23 +0,0 @@{% extends 'base.html' %}{% load static %}{% block title %}500{% endblock %}{% block description %}Something went wrong.{% endblock %}{% block breadcrumbs %}{% endblock %}{% block main %}<div class="container">  <div class="row">    <div class="col text-center py-5 my-5">      <div class="section-label mb-3" style="justify-content:center;">internal error</div>      <h1 class="display-1 text-white fw-bolder mb-3" style="letter-spacing: -0.02em;">500</h1>      <p class="text-muted">Something went wrong on our end. Try again in a moment.</p>      <a href="/" class="btn btn-outline-light mt-3">Home →</a>    </div>  </div></div>{% endblock %}
deleted analytics/templates/includes/collector.html
@@ -1,9 +0,0 @@{% if collector_id %}<script>  (function(m,e,t,r,i,c,s){m.collectorQueue = m.collectorQueue || r;  m.collectorServer = c; m.collectorId = s; collectorScript = e.createElement(t);  collectorScript.src = c + i; e.head.appendChild(m.collectorScript);  })(window,document,'script',[],'/static/collector.js',  '{{ collector_server }}','{{ collector_id }}');</script>{% endif %}
deleted analytics/templates/includes/social.html
@@ -1,9 +0,0 @@{% load social %}<meta property="og:type" content="website"><meta property="og:title" content="{{ title }} · Analytics"><meta property="og:description" content="{{ description }}"><meta property="og:url" content="{{ canonical }}">{% og_image canonical %}<meta name="twitter:card" content="summary_large_image">
deleted analytics/templates/registration/logged_out.html
@@ -1,25 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">session · terminated</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">You've signed out. Sign in again to get back to your properties.</p>      <a href="{% url 'login' %}" class="btn btn-primary">Login →</a>    </div>  </div></div>{% endblock %}
deleted analytics/templates/registration/login.html
@@ -1,73 +0,0 @@{% extends "base.html" %}{% load i18n static %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="auth-shell">  <div class="auth-form-col">    <div class="auth-form-wrap">      <div class="section-label mb-2">authenticate</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em; font-size: 1.9rem;">{{ title }}</h1>      <p class="text-muted small mb-4">Access is invite-only. Contact the operator for credentials.</p>      {% if form.errors and not form.non_field_errors %}      <div class="alert alert-warning py-2 small">        {% if form.errors.items|length == 1 %}{% translate "Please correct the error below." %}{% else %}{% translate "Please correct the errors below." %}{% endif %}      </div>      {% endif %}      {% if form.non_field_errors %}      {% for error in form.non_field_errors %}      <div class="alert alert-danger py-2 small">{{ error }}</div>      {% endfor %}      {% endif %}      {% if user.is_authenticated %}      <div class="alert alert-warning py-2 small">        {% blocktranslate trimmed %}          You are authenticated as {{ username }}, but are not authorized to access this page.        {% endblocktranslate %}      </div>      {% endif %}      <form method="POST" action="{% url 'login' %}">        {% csrf_token %}        <input type="hidden" name="next" value="{{ next }}" />        <div class="form-floating mb-3">          <input type="text" class="form-control {% if form.username.errors %}is-invalid{% endif %}" name="username" id="id_username" placeholder="Username" value="{{ form.username.value|default:'' }}" required autofocus />          <label for="id_username">Username</label>        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.password.errors %}is-invalid{% endif %}" name="password" id="id_password" placeholder="Password" required />          <label for="id_password">Password</label>        </div>        <div class="d-flex justify-content-between align-items-center mt-4">          <button type="submit" class="btn btn-primary">Login →</button>          <a href="{% url 'password_reset' %}" class="btn btn-link small">Forgot password?</a>        </div>      </form>    </div>  </div>  <div class="auth-visual-col">    <div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; padding: 2rem; z-index: 3;">      <div class="terminal-block" style="max-width: 380px; width: 100%;">        <span class="t-line"><span class="t-comment"># analytics · collector</span></span>        <span class="t-line"><span class="t-prompt">operator$</span><span class="t-out">whoami</span></span>        <span class="t-line"><span class="t-val">anonymous</span></span>        <span class="t-line"><span class="t-prompt">operator$</span><span class="t-out">login --mode=interactive</span></span>        <span class="t-line"><span class="t-key">awaiting</span>=<span class="t-val">credentials</span> <span class="t-cursor"></span></span>      </div>    </div>  </div></div>{% endblock %}
deleted analytics/templates/registration/password_change_done.html
@@ -1,26 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'profile' %}">Profile</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">account · security</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <div class="alert alert-success py-2 small">Your password was changed.</div>      <a href="{% url 'profile' %}" class="btn btn-outline-light">Back to profile →</a>    </div>  </div></div>{% endblock %}
deleted analytics/templates/registration/password_change_form.html
@@ -1,55 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'profile' %}">Profile</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">account · security</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">Enter your current password, then your new password twice to confirm.</p>      <form method="POST">        {% csrf_token %}        {% if form.errors %}        <div class="alert alert-warning py-2 small">          {% if form.errors.items|length == 1 %}Please correct the error below.{% else %}Please correct the errors below.{% endif %}        </div>        {% endif %}        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.old_password.errors %}is-invalid{% endif %}" name="old_password" id="id_old_password" placeholder="Old password" autocomplete="current-password" required autofocus />          <label for="id_old_password">Old password</label>          {{ form.old_password.errors }}        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password1.errors %}is-invalid{% endif %}" name="new_password1" id="id_new_password1" placeholder="New password" autocomplete="new-password" required />          <label for="id_new_password1">New password</label>          {% if form.new_password1.help_text %}          <div class="alert alert-info py-2 px-3 mt-2 small">{{ form.new_password1.help_text|safe }}</div>          {% endif %}          {{ form.new_password1.errors }}        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}" name="new_password2" id="id_new_password2" placeholder="Confirm new password" autocomplete="new-password" required />          <label for="id_new_password2">Confirm new password</label>          {{ form.new_password2.errors }}        </div>        <button type="submit" class="btn btn-primary">Change password →</button>      </form>    </div>  </div></div>{% endblock %}
deleted analytics/templates/registration/password_reset_complete.html
@@ -1,26 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">account · recovery</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <div class="alert alert-success py-2 small">Your password has been set. You can log in now.</div>      <a href="{{ login_url }}" class="btn btn-primary">Login →</a>    </div>  </div></div>{% endblock %}
deleted analytics/templates/registration/password_reset_confirm.html
@@ -1,48 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">account · recovery</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      {% if validlink %}      <p class="text-muted small mb-4">Enter your new password twice to confirm.</p>      <form method="POST">        {% csrf_token %}        <input type="hidden" autocomplete="username" value="{{ form.user.get_username }}" />        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password1.errors %}is-invalid{% endif %}" name="new_password1" id="id_new_password1" placeholder="New password" autocomplete="new-password" required autofocus />          <label for="id_new_password1">New password</label>          {{ form.new_password1.errors }}        </div>        <div class="form-floating mb-3">          <input type="password" class="form-control {% if form.new_password2.errors %}is-invalid{% endif %}" name="new_password2" id="id_new_password2" placeholder="Confirm password" autocomplete="new-password" required />          <label for="id_new_password2">Confirm password</label>          {{ form.new_password2.errors }}        </div>        <button type="submit" class="btn btn-primary">Change password →</button>      </form>      {% else %}      <div class="alert alert-danger py-2 small">        This password reset link is invalid — it may have already been used. Request a new link from the        <a href="{% url 'password_reset' %}">password reset form</a>.      </div>      {% endif %}    </div>  </div></div>{% endblock %}
deleted analytics/templates/registration/password_reset_done.html
@@ -1,25 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">account · recovery</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small">We've emailed reset instructions if an account matched that address. Check your inbox — and your spam folder.</p>    </div>  </div></div>{% endblock %}
deleted analytics/templates/registration/password_reset_email.html
@@ -1,14 +0,0 @@{% load i18n %}{% autoescape off %}{% blocktranslate %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktranslate %}{% translate "Please go to the following page and choose a new password:" %}{% block reset_link %}{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}{% endblock %}{% translate 'Your username, in case you’ve forgotten:' %} {{ user.get_username }}{% translate "Thanks for using our site!" %}{% blocktranslate %}The {{ site_name }} team{% endblocktranslate %}{% endautoescape %}
deleted analytics/templates/registration/password_reset_form.html
@@ -1,34 +0,0 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="{% url 'login' %}">Login</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-5">  <div class="row">    <div class="col-lg-6 offset-lg-3 col-md-8 offset-md-2">      <div class="section-label mb-2">account · recovery</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em;">{{ title }}</h1>      <p class="text-muted small mb-4">Enter your email and we'll send a link to set a new password.</p>      <form method="POST">        {% csrf_token %}        <div class="form-floating mb-3">          <input type="email" class="form-control {% if form.email.errors %}is-invalid{% endif %}" name="email" id="id_email" placeholder="Email" value="{{ form.email.value|default:'' }}" required autofocus />          <label for="id_email">Email</label>        </div>        <button type="submit" class="btn btn-primary">Send reset link →</button>      </form>    </div>  </div></div>{% endblock %}
deleted analytics/urls.py
@@ -1,26 +0,0 @@from django.conf import settingsfrom django.conf.urls.static import staticfrom django.contrib import adminfrom django.urls import include, pathfrom django.views.generic import TemplateViewfrom accounts import urls as accounts_urlsfrom pages import urls as pages_urlsfrom properties import urls as properties_urlsfrom collector import urls as collector_urlsurlpatterns = [    path("admin/", admin.site.urls),    path("accounts/", include(accounts_urls)),    path("properties/", include(properties_urls)),    path("", include(collector_urls)),    path("", include(pages_urls)),]if settings.DEBUG:    urlpatterns.append(path("403/", TemplateView.as_view(template_name="403.html")))    urlpatterns.append(path("404/", TemplateView.as_view(template_name="404.html")))    urlpatterns.append(path("500/", TemplateView.as_view(template_name="500.html")))    urlpatterns += static("media/", document_root=settings.MEDIA_ROOT)
deleted analytics/wsgi.py
@@ -1,16 +0,0 @@"""WSGI config for analytics project.It exposes the WSGI callable as a module-level variable named ``application``.For more information on this file, seehttps://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/"""import osfrom django.core.wsgi import get_wsgi_applicationos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'analytics.settings.development')application = get_wsgi_application()
deleted collector/__init__.py
deleted collector/apps.py
@@ -1,6 +0,0 @@from django.apps import AppConfigclass CollectorConfig(AppConfig):    default_auto_field = 'django.db.models.BigAutoField'    name = 'collector'
deleted collector/urls.py
@@ -1,8 +0,0 @@from django.urls import pathfrom . import viewsurlpatterns = [    path('collect/', views.collect, name='collect'),]
deleted collector/views.py
@@ -1,121 +0,0 @@import jsonfrom django.http import HttpResponsefrom django.views.decorators.csrf import csrf_exemptfrom geoip2.errors import AddressNotFoundErrorfrom user_agents import parse as ua_parsetry:    from django.contrib.gis.geoip2 import GeoIP2, GeoIP2Exceptionexcept ImportError:  # GeoIP2 dependencies are optional    GeoIP2 = None    class GeoIP2Exception(Exception):        """Fallback exception when GeoIP2 isn't available."""from properties.models import Event, Property@csrf_exemptdef collect(request):    """    Processes collector events sent to our server, stores them using Event for    the relevant Site.    """    if request.method == 'OPTIONS':        response = HttpResponse(status=204)        response['Allow'] = 'OPTIONS, POST'        response['Access-Control-Allow-Methods'] = 'OPTIONS, POST'        response['Access-Control-Allow-Headers'] = request.headers.get('Access-Control-Request-Headers', 'Content-Type')        response['Access-Control-Allow-Origin'] = request.headers.get('Origin', '*')        return response    if request.method != 'POST':        return HttpResponse(status=405)    raw_body = request.body    if not raw_body:        return HttpResponse(status=400)    try:        body = json.loads(raw_body)    except json.JSONDecodeError:        return HttpResponse(status=400)    collector_id = body.get('collectorId')    event_name = body.get('event')    if collector_id is None or event_name is None:        return HttpResponse(status=400)    try:        property_obj = Property.objects.get(id=collector_id)    except Property.DoesNotExist:        return HttpResponse(status=404)    event_data = body.get('data', {})    if not isinstance(event_data, dict):        return HttpResponse(status=400)    event_obj = Event(        property=property_obj,        event=event_name,        data=event_data,    )    # If we have a data__referrer then strip the url down to just the hostname    # ex. "example.com" all lowercase.    if 'referrer' in event_obj.data:        # Some urls have a query string, some have a fragment, some have more        # need to strip everything before the protocol and after the tld        # ex. "http://example.com/foo?bar=baz#frag" -> "example.com"        event_obj.data['referrer'] = event_obj.data['referrer'].split('://')[-1].split('/')[0].lower().replace('www.', '')    try:        if event_obj.event == 'session_start' and GeoIP2 is not None:            # Check HTTP_X_FORWARDED_FOR first item after split , for the client IP            # if it exists else use REMOTE_ADDR            ip = request.META.get('HTTP_X_FORWARDED_FOR', request.META.get('REMOTE_ADDR')).split(',')[0]            if ip != '127.0.0.1':                g = GeoIP2()                g_data = g.city(ip)                if g_data:                    event_obj.data['country'] = g_data['country_code']                    # Some MMDB providers (e.g. DB-IP free) don't populate the                    # ISO subdivision code — only the full name. Prefer the                    # name so we get a value either way; the world map's                    # region lookup matches against both forms.                    event_obj.data['region'] = g_data.get('region_name') or g_data.get('region')                    event_obj.data['city'] = g_data['city']                    event_obj.data['loc'] = [g_data['latitude'], g_data['longitude']]    except (GeoIP2Exception, AddressNotFoundError):        pass    # If we have a "user_agent" in "data" then parse it and store the results in    # data under "platform", "device" and "browser".    ua = None    if 'user_agent' in event_obj.data:        ua = ua_parse(event_obj.data['user_agent'])    # If we don't have a ua in the event_obj.data lets see if the request has    # one to parse.    if not ua and request.META.get('HTTP_USER_AGENT'):        ua = ua_parse(request.META.get('HTTP_USER_AGENT'))    if ua:        event_obj.data['platform'] = ua.os.family        event_obj.data['browser'] = ua.browser.family        if ua.is_mobile:            event_obj.data['device'] = 'Mobile'        elif ua.is_tablet:            event_obj.data['device'] = 'Tablet'        else:            event_obj.data['device'] = 'Desktop'        if ua.is_bot:            event_obj.data['is_bot'] = True            event_obj.data['bot_name'] = ua.browser.family or 'Unknown bot'    event_obj.save()    return HttpResponse(status=204)
modified docker-compose.yml
@@ -1,15 +1,14 @@services:  web:    container_name: analytics_web    container_name: analytics    build: .    init: true    env_file: .env    volumes:      - /srv/data/analytics/:/data/    ports:      - "127.0.0.1:${PORT}:${PORT}"    command: >      sh -c "python manage.py refresh_geoip;      exec gunicorn analytics.asgi:application --preload --workers 2 --max-requests 256      --timeout 30 --bind :${PORT} --worker-class uvicorn.workers.UvicornWorker      --error-logfile - --access-logfile -"    environment:      PORT: ${PORT}      ANALYTICS_DATA_DIR: /data    volumes:      - /srv/data/analytics:/data    restart: unless-stopped
renamed bun.lock → frontend/bun.lock
@@ -6,7 +6,7 @@      "dependencies": {        "@fontsource/monaspace-argon": "^5.2.5",        "@popperjs/core": "^2.11.5",        "bootstrap": "^5.1.3",        "bootstrap": "^5.3.3",        "chart.js": "^4.5.1",        "d3-geo": "^3.1.1",        "d3-scale": "^4.0.2",
@@ -109,55 +109,55 @@    "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],    "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],    "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="],    "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],    "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="],    "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],    "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="],    "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],    "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="],    "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],    "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="],    "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],    "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="],    "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],    "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="],    "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],    "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="],    "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],    "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="],    "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],    "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="],    "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],    "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="],    "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],    "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="],    "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],    "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="],    "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],    "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="],    "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],    "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="],    "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],    "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="],    "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],    "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="],    "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],    "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="],    "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],    "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="],    "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],    "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="],    "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],    "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="],    "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],    "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="],    "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],    "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="],    "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],    "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="],    "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],    "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="],    "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -217,7 +217,7 @@    "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="],    "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],    "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],    "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
@@ -225,11 +225,11 @@    "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],    "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],    "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],    "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],    "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],    "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],    "sass": ["sass@1.99.0", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="],
@@ -239,7 +239,7 @@    "topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],    "topojson-server": ["topojson-server@3.0.0", "", { "dependencies": { "commander": "2" }, "bin": { "geo2topo": "bin/geo2topo" } }, "sha512-UhhwQk4e2+lwhAVYkja3J5nQHQmKwORDuIQPkMnFFZFcLqWKLQWI3u7fZWtNIXTElBjTYdBUL1kzi1+oS/qDQw=="],    "topojson-server": ["topojson-server@3.0.1", "", { "dependencies": { "commander": "2" }, "bin": { "geo2topo": "bin/geo2topo" } }, "sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw=="],    "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],  }
renamed package.json → frontend/package.json
@@ -4,12 +4,12 @@  "scripts": {    "dev": "vite build --watch",    "build": "vite build",    "build:maps": "bun analytics/scripts/build_maps.js"    "build:maps": "bun scripts/build_maps.js"  },  "dependencies": {    "@fontsource/monaspace-argon": "^5.2.5",    "@popperjs/core": "^2.11.5",    "bootstrap": "^5.1.3",    "bootstrap": "^5.3.3",    "chart.js": "^4.5.1",    "d3-geo": "^3.1.1",    "d3-scale": "^4.0.2",
renamed analytics/scripts/build_maps.js → frontend/scripts/build_maps.js
@@ -18,7 +18,7 @@ import { fileURLToPath } from "node:url";import { topology } from "topojson-server";const __dirname = dirname(fileURLToPath(import.meta.url));const OUT_DIR = resolve(__dirname, "../static_maps");const OUT_DIR = resolve(__dirname, "../../static_maps");// 110m for the always-on world view (small, ~30 KB topojson). 10m for// admin-1 because the 50m and 110m bundles only ship four big countries —
renamed analytics/static_src/index.js → frontend/static_src/base/index.js
renamed analytics/static_src/scripts/bootstrap.js → frontend/static_src/base/scripts/bootstrap.js
renamed analytics/static_src/styles/_variables.scss → frontend/static_src/base/styles/_variables.scss
renamed analytics/static_src/styles/base.scss → frontend/static_src/base/styles/base.scss
renamed analytics/static_src/styles/bootstrap.scss → frontend/static_src/base/styles/bootstrap.scss
renamed collector/static_src/index.js → frontend/static_src/collector/index.js
renamed collector/static_src/scripts/collector.js → frontend/static_src/collector/scripts/collector.js
@@ -44,7 +44,7 @@    collectorUserId = Math.floor(Math.random() * 1000000000);    set_cookie("collectoruserid", collectorUserId, 365);    window.collectorQueue.push({      collector_id: window.collectorId,      collectorId: window.collectorId,      event: "session_start",      data: {        user_id: collectorUserId,
@@ -103,7 +103,7 @@  // send a page view event  window.collectorQueue.push({    collector_id: window.collectorId,    collectorId: window.collectorId,    event: "page_view",    data: {      user_id: collectorUserId,
@@ -119,7 +119,7 @@  // send click and auxclick events  document.addEventListener("click", function (event) {    window.collectorQueue.push({      collector_id: window.collectorId,      collectorId: window.collectorId,      event: "click",      data: {        user_id: collectorUserId,
@@ -138,7 +138,7 @@  window.addEventListener("scroll", function () {    if (new Date().getTime() - last_scroll_event > 1000) {      window.collectorQueue.push({        collector_id: window.collectorId,        collectorId: window.collectorId,        event: "scroll",        data: {          user_id: collectorUserId,
@@ -174,7 +174,7 @@    var now = new Date().getTime();    var time_on_page = visible_accumulated + (visible_since !== null ? now - visible_since : 0);    window.collectorQueue.push({      collector_id: window.collectorId,      collectorId: window.collectorId,      event: "page_leave",      data: {        user_id: collectorUserId,
renamed pages/static_src/index.js → frontend/static_src/pages/index.js
renamed pages/static_src/styles/pages.scss → frontend/static_src/pages/styles/pages.scss
@@ -1,4 +1,4 @@@use "../../../analytics/static_src/styles/variables" as *;@use "../../base/styles/variables" as *;// ── Home hero ──
renamed properties/static_src/index.js → frontend/static_src/properties/index.js
renamed properties/static_src/scripts/property_custom_cards.js → frontend/static_src/properties/scripts/property_custom_cards.js
renamed properties/static_src/scripts/property_date_select.js → frontend/static_src/properties/scripts/property_date_select.js
renamed properties/static_src/scripts/property_filters.js → frontend/static_src/properties/scripts/property_filters.js
renamed properties/static_src/scripts/property_graphs.js → frontend/static_src/properties/scripts/property_graphs.js
renamed properties/static_src/scripts/property_is_public.js → frontend/static_src/properties/scripts/property_is_public.js
renamed properties/static_src/scripts/property_map.js → frontend/static_src/properties/scripts/property_map.js
@@ -8,7 +8,7 @@ import { scaleLinear } from "d3-scale";import { select } from "d3-selection";import { feature } from "topojson-client";const STATIC_BASE = "/static";const STATIC_BASE = "/static_maps";const WORLD_URL = `${STATIC_BASE}/world.json`;const ADMIN1_URL = (iso) => `${STATIC_BASE}/admin1/${iso}.json`;
added frontend/vite.config.js
@@ -0,0 +1,43 @@import { resolve } from "path";import { defineConfig } from "vite";// Vite output goes to ../dist; Rust serves it at /static/.// Manifest is read at runtime so templates resolve hashed asset names.// Four entry points mirror the original Django split: base (shared shell),// pages (marketing static pages), properties (dashboard charts/map), and// collector (the public embed script).export default defineConfig({  base: "/static/",  build: {    outDir: resolve(__dirname, "../dist"),    emptyOutDir: true,    manifest: true,    rollupOptions: {      input: {        base: resolve(__dirname, "static_src/base/index.js"),        pages: resolve(__dirname, "static_src/pages/index.js"),        properties: resolve(__dirname, "static_src/properties/index.js"),        collector: resolve(__dirname, "static_src/collector/index.js"),      },      output: {        // Hashed asset filenames so we can cache them aggressively.        entryFileNames: "assets/[name]-[hash].js",        chunkFileNames: "assets/[name]-[hash].js",        assetFileNames: (assetInfo) => {          if (/\.(png|jpg|gif|svg|webp)$/.test(assetInfo.name || "")) {            return "images/[name]-[hash][extname]";          }          return "assets/[name]-[hash][extname]";        },      },    },  },  css: {    preprocessorOptions: {      scss: {        quietDeps: true,      },    },  },});
deleted manage.py
@@ -1,22 +0,0 @@#!/usr/bin/env python"""Django's command-line utility for administrative tasks."""import osimport sysdef main():    """Run administrative tasks."""    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'analytics.settings.development')    try:        from django.core.management import execute_from_command_line    except ImportError as exc:        raise ImportError(            "Couldn't import Django. Are you sure it's installed and "            "available on your PYTHONPATH environment variable? Did you "            "forget to activate a virtual environment?"        ) from exc    execute_from_command_line(sys.argv)if __name__ == '__main__':    main()
added migrations/0001_initial.sql
@@ -0,0 +1,59 @@CREATE TABLE properties (    id            BLOB PRIMARY KEY,    name          TEXT NOT NULL,    custom_cards  TEXT NOT NULL DEFAULT '[]',    is_protected  INTEGER NOT NULL DEFAULT 0,    is_public     INTEGER NOT NULL DEFAULT 0,    created_at    INTEGER NOT NULL,    updated_at    INTEGER NOT NULL);CREATE TABLE events (    id              INTEGER PRIMARY KEY AUTOINCREMENT,    property_id     BLOB NOT NULL REFERENCES properties(id) ON DELETE CASCADE,    event           TEXT NOT NULL,    created_at      INTEGER NOT NULL,    user_id         TEXT,    url             TEXT,    title           TEXT,    referrer        TEXT,    user_agent      TEXT,    platform        TEXT,    browser         TEXT,    device          TEXT,    screen_width    INTEGER,    screen_height   INTEGER,    country         TEXT,    region          TEXT,    city            TEXT,    lat             REAL,    lon             REAL,    utm_source      TEXT,    utm_medium      TEXT,    utm_campaign    TEXT,    utm_term        TEXT,    utm_content     TEXT,    time_on_page_ms INTEGER,    extra           TEXT NOT NULL DEFAULT '{}');CREATE INDEX events_property_created       ON events(property_id, created_at);CREATE INDEX events_property_event_created ON events(property_id, event, created_at);CREATE TABLE bot_events (    id           INTEGER PRIMARY KEY AUTOINCREMENT,    property_id  BLOB NOT NULL REFERENCES properties(id) ON DELETE CASCADE,    event        TEXT NOT NULL,    created_at   INTEGER NOT NULL,    bot_name     TEXT,    url          TEXT,    user_agent   TEXT,    country      TEXT,    extra        TEXT NOT NULL DEFAULT '{}');CREATE INDEX bot_events_property_created ON bot_events(property_id, created_at);-- meta key-value table for things like the Proprium property idCREATE TABLE meta (    key   TEXT PRIMARY KEY,    value TEXT NOT NULL);
deleted pages/__init__.py
deleted pages/apps.py
@@ -1,6 +0,0 @@from django.apps import AppConfigclass PagesConfig(AppConfig):    default_auto_field = 'django.db.models.BigAutoField'    name = 'pages'
deleted pages/models.py
@@ -1,3 +0,0 @@from django.db import models# Create your models here.
deleted pages/templates/robots.txt
@@ -1,4 +0,0 @@User-agent: *Allow: /Sitemap: {{BASE_URL}}/sitemap.xml
deleted pages/templates/sitemap.xml
@@ -1,15 +0,0 @@<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">  <url>    <loc>{{BASE_URL}}/</loc>    <lastmod>2022-06-06</lastmod>  </url>  <url>    <loc>{{BASE_URL}}/changelog/</loc>    <lastmod>2022-06-06</lastmod>  </url>  <url>    <loc>{{BASE_URL}}/documentation/</loc>    <lastmod>2022-06-06</lastmod>  </url></urlset>
deleted pages/templatetags/__init__.py
deleted pages/templatetags/social.py
@@ -1,38 +0,0 @@"""Template tags for social media and sharing in general."""import hashlibimport ioimport threadingfrom django import templatefrom django.core.files.storage import default_storagefrom django.utils.html import format_htmlfrom django.utils import timezonefrom analytics.chromium import generate_screenshot_from_urlregister = template.Library()@register.simple_tagdef og_image(url):    filename = hashlib.md5(url.encode("utf-8")).hexdigest()    filename = f"opengraph/{filename}.png"    if not default_storage.exists(filename):        # save a quick file to prevent infinite loop        default_storage.save(filename, io.BytesIO())        generate_screenshot_from_url(url, filename)    else:        # if the file exists, check it's timestamp, if it's older than a day        # regenerate it with threading since the old one is okay for now        one_day_ago = timezone.now() - timezone.timedelta(days=1)        if default_storage.get_modified_time(filename) < one_day_ago:            threading.Thread(                target=generate_screenshot_from_url, args=(url, filename)            ).start()    # get full url including domain    url = default_storage.url(filename)    return format_html('<meta property="og:image" content="{}">', url)
deleted pages/urls.py
@@ -1,13 +0,0 @@from django.urls import pathfrom . import viewsurlpatterns = [    path('documentation/', views.documentation, name='documentation'),    path('changelog/', views.changelog, name='changelog'),    path('favicon.ico', views.favicon, name='favicon'),    path('robots.txt', views.robots, name='robots'),    path('sitemap.xml', views.sitemap, name='sitemap'),    path('', views.home, name='home'),]
deleted pages/views.py
@@ -1,66 +0,0 @@from django.shortcuts import render, redirectfrom django.http import HttpResponsefrom properties.models import Event, Propertyfrom accounts.models import Userdef home(request):    if request.user.is_authenticated:        return redirect('properties')    context = {}    context['title'] = 'Home'    context['description'] = 'Made by Isaac Bythewood, simple analytics for people who want to host their own and hack on it a bit.'    total_events = Event.objects.all().count()    context['total_events'] = total_events    total_properties = Property.objects.all().count()    context['total_properties'] = total_properties    total_users = User.objects.all().count()    context['total_users'] = total_users    first_event_created_at = Event.objects.all().order_by('created_at').first().created_at    context['first_event_created_at'] = first_event_created_at    return render(request, 'pages/home.html', context)def changelog(request):    context = {}    context['title'] = 'Changelog'    context['description'] = 'An ongoing changelog and upcoming list of features for Analytics.'    return render(request, 'pages/changelog.html', context)def documentation(request):    context = {}    context['title'] = 'Documentation'    context['description'] = 'Documentation for Analytics.'    return render(request, 'pages/documentation.html', context)def favicon(request):    # Rising-bars mark — a four-bar histogram in mossy green with an amber    # cap on the tallest column. Matches the in-app logo, evokes "aggregate    # counts over time" which is what Analytics actually tracks.    svg = (        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'        '<rect x="6"  y="38" width="10" height="22" rx="1.5" fill="#6b9e78"/>'        '<rect x="20" y="28" width="10" height="32" rx="1.5" fill="#6b9e78"/>'        '<rect x="34" y="18" width="10" height="42" rx="1.5" fill="#6b9e78"/>'        '<rect x="48" y="8"  width="10" height="52" rx="1.5" fill="#6b9e78"/>'        '<rect x="48" y="8"  width="10" height="6"  rx="1.5" fill="#c9a84c"/>'        "</svg>"    )    return HttpResponse(svg, content_type="image/svg+xml")def robots(request):    return render(request, 'robots.txt', content_type='text/plain')def sitemap(request):    return render(request, 'sitemap.xml', content_type='text/xml')
deleted properties/__init__.py
deleted properties/admin.py
@@ -1,21 +0,0 @@from django.contrib import adminfrom .models import Property, Eventclass PropertyAdmin(admin.ModelAdmin):    list_display = ('id', 'name', 'user', 'total_events', 'total_page_views', 'total_clicks',)    list_filter = ('user__username',)    search_fields = ('name', 'user__username',)    ordering = ('user',)admin.site.register(Property, PropertyAdmin)class EventAdmin(admin.ModelAdmin):    list_display = ('property', 'event', 'created_at',)    list_filter = ('property__name', 'created_at')admin.site.register(Event, EventAdmin)
deleted properties/apps.py
@@ -1,6 +0,0 @@from django.apps import AppConfigclass PropertiesConfig(AppConfig):    default_auto_field = 'django.db.models.BigAutoField'    name = 'properties'
deleted properties/constants.py
@@ -1 +0,0 @@BUILT_IN_EVENTS = ["session_start", "page_view", "page_leave", "click", "scroll"]
deleted properties/forms.py
@@ -1,9 +0,0 @@from django import formsfrom .models import Propertyclass PropertyForm(forms.ModelForm):    class Meta:        model = Property        fields = ('name',)
deleted properties/management/__init__.py
deleted properties/management/commands/__init__.py
deleted properties/management/commands/prune_events.py
@@ -1,24 +0,0 @@from django.core.management.base import BaseCommandfrom django.utils import timezonefrom properties.models import Eventclass Command(BaseCommand):    help = "Delete Event rows older than --days (default 730)."    def add_arguments(self, parser):        parser.add_argument("--days", type=int, default=730)        parser.add_argument("--dry-run", action="store_true")    def handle(self, *args, **options):        cutoff = timezone.now() - timezone.timedelta(days=options["days"])        qs = Event.objects.filter(created_at__lt=cutoff)        count = qs.count()        if options["dry_run"]:            self.stdout.write(f"Would delete {count} events older than {cutoff.isoformat()}")            return        deleted, _ = qs.delete()        self.stdout.write(f"Deleted {deleted} events older than {cutoff.isoformat()}")
deleted properties/management/commands/refresh_geoip.py
@@ -1,111 +0,0 @@"""Download or refresh the DB-IP City Lite GeoIP database.DB-IP publishes a fresh build on the 1st of each month at:    https://download.db-ip.com/free/dbip-city-lite-YYYY-MM.mmdb.gzLicense is CC-BY-4.0 — attribution lives in the dashboard footer. No account,no API key. The mmdb format is MaxMind-compatible so the existing geoip2reader picks it up unchanged.Run on container start (idempotent, skips if the on-disk file is fresh) andagain monthly via host cron."""import gzipimport osimport shutilimport sysimport tempfileimport urllib.errorimport urllib.requestfrom datetime import date, timedeltafrom pathlib import Pathfrom django.conf import settingsfrom django.core.management.base import BaseCommandURL_TEMPLATE = "https://download.db-ip.com/free/dbip-city-lite-{year}-{month:02d}.mmdb.gz"USER_AGENT = "analytics-refresh-geoip/1.0 (+https://github.com/overshard/analytics)"MAX_AGE_DAYS = 30def _candidate_months(today=None):    """    Yield (year, month) tuples to try, newest first.    DB-IP publishes on the 1st but may lag a few hours; if the current month    isn't up yet, fall back to the previous month, and one more before that    in case we're catching up after a long outage.    """    today = today or date.today()    for offset in (0, 1, 2):        d = (today.replace(day=1) - timedelta(days=offset * 28)).replace(day=1)        yield d.year, d.monthclass Command(BaseCommand):    help = "Download (or refresh) the DB-IP City Lite GeoIP database to GEOIP_PATH."    def add_arguments(self, parser):        parser.add_argument(            "--force",            action="store_true",            help="Re-download even if the existing file is younger than 30 days.",        )    def handle(self, *args, **options):        target = Path(getattr(settings, "GEOIP_PATH", "")).resolve()        if not target.parent.exists():            self.stderr.write(f"GEOIP_PATH parent dir does not exist: {target.parent}")            sys.exit(0)        if not options["force"] and target.exists():            age_days = (date.today() - date.fromtimestamp(target.stat().st_mtime)).days            if age_days < MAX_AGE_DAYS:                self.stdout.write(f"GeoIP database is {age_days}d old; skipping refresh.")                return        last_error = None        for year, month in _candidate_months():            url = URL_TEMPLATE.format(year=year, month=month)            try:                self.stdout.write(f"Fetching {url}")                self._download(url, target)                self.stdout.write(self.style.SUCCESS(f"GeoIP database updated at {target}"))                return            except urllib.error.HTTPError as e:                if e.code == 404:                    self.stdout.write(f"  not yet published ({year}-{month:02d})")                    last_error = e                    continue                last_error = e                break            except (urllib.error.URLError, OSError) as e:                last_error = e                break        # Non-fatal: dashboard works without GeoIP, collector silently skips        # enrichment when the file is missing or stale.        self.stderr.write(f"GeoIP refresh failed: {last_error}")        sys.exit(0)    def _download(self, url, target):        request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})        with urllib.request.urlopen(request, timeout=120) as response:            tmp_dir = target.parent            with tempfile.NamedTemporaryFile(                dir=tmp_dir, prefix=".geoip-", suffix=".mmdb", delete=False            ) as tmp:                tmp_path = Path(tmp.name)                try:                    with gzip.GzipFile(fileobj=response) as gz:                        shutil.copyfileobj(gz, tmp)                    tmp.flush()                    os.fsync(tmp.fileno())                except Exception:                    tmp_path.unlink(missing_ok=True)                    raise        os.replace(tmp_path, target)
deleted properties/migrations/0001_initial.py
@@ -1,49 +0,0 @@# Generated by Django 4.0.4 on 2022-05-29 02:56from django.conf import settingsfrom django.db import migrations, modelsimport django.db.models.deletionimport uuidclass Migration(migrations.Migration):    initial = True    dependencies = [        migrations.swappable_dependency(settings.AUTH_USER_MODEL),    ]    operations = [        migrations.CreateModel(            name='Property',            fields=[                ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),                ('name', models.CharField(max_length=255)),                ('custom_cards', models.JSONField(default=list)),                ('created_at', models.DateTimeField(auto_now_add=True)),                ('updated_at', models.DateTimeField(auto_now=True)),                ('is_protected', models.BooleanField(default=False, editable=False)),                ('is_public', models.BooleanField(default=False, editable=False)),                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='properties', to=settings.AUTH_USER_MODEL)),            ],            options={                'verbose_name': 'Property',                'verbose_name_plural': 'Properties',            },        ),        migrations.CreateModel(            name='Event',            fields=[                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),                ('created_at', models.DateTimeField(auto_now_add=True)),                ('event', models.CharField(editable=False, max_length=255)),                ('data', models.JSONField(editable=False)),                ('property', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='events', to='properties.property')),            ],        ),        migrations.AddIndex(            model_name='event',            index=models.Index(fields=['created_at'], name='properties__created_074434_idx'),        ),    ]
deleted properties/migrations/0002_initial_data.py
@@ -1,265 +0,0 @@import randomfrom django.db import migrationsfrom django.utils import timezonedef create_proprium(apps, schema_editor):    """    Makes an initial Property named 'Proprium' for us to use for our own    analytics. Sets the user to our initial "admin" user.    """    User = apps.get_model('accounts', 'User')    Property = apps.get_model('properties', 'Property')    user = User.objects.get(username='admin')    Property.objects.create(name='Proprium', user=user, is_protected=True)def create_exemplar(apps, schema_editor):    """    Makes an initial Property named 'Exemplar' for us to add a bunch of sample    events to it for testing. Sets the user to our initial "admin" user.    """    User = apps.get_model('accounts', 'User')    Property = apps.get_model('properties', 'Property')    Event = apps.get_model('properties', 'Event')    user = User.objects.get(username='admin')    property = Property.objects.create(name='Exemplar', user=user)    url_paths = [        {'path': '/', 'title': 'Home'},        {'path': '/about', 'title': 'About'},        {'path': '/contact', 'title': 'Contact'},        {'path': '/blog', 'title': 'Blog'},        {'path': '/blog/post-1', 'title': 'Post 1'},        {'path': '/blog/post-2', 'title': 'Post 2'},        {'path': '/blog/post-3', 'title': 'Post 3'},    ]    screen_sizes = [        {'width': 1024, 'height': 768},        {'width': 1280, 'height': 1024},        {'width': 1366, 'height': 768},        {'width': 1920, 'height': 1080},        {'width': 2560, 'height': 1440},    ]    platforms = [        'Windows',        'Macintosh',        'Linux',        'Android',        'iOS',    ]    user_ids = [        random.randint(100000000, 999999999) for _ in range(random.randint(100, 150))    ]    custom_events = [        "Newsletter Signup",        "Checkout Success",        "New User Signup",    ]    referrer_urls = [        'google.com',        'bing.com',        'yahoo.com',        'duckduckgo.com',        'ask.com',        'baidu.com',        'yandex.com',        'facebook.com',        'twitter.com',        'linkedin.com',        'reddit.com',        'pinterest.com',        'youtube.com',        'instagram.com',        'flickr.com',        'tumblr.com',    ]    # Create a list of dicts that include "city", "region", "country", and    # "loc" which is "lat,lon" in the US.    random_locations = [        { 'region': 'New York', },        { 'region': 'California', },        { 'region': 'Texas', },        { 'region': 'North Carolina', },        { 'region': 'Florida', },        { 'region': 'Illinois', },        { 'region': 'Ohio', },        { 'region': 'Michigan', },        { 'region': 'Pennsylvania', },        { 'region': 'Georgia', },        { 'region': 'New Jersey', },        { 'region': 'Virginia', },        { 'region': 'North Dakota', },        { 'region': 'South Carolina', },        { 'region': 'Indiana', },    ]    random_utm_medium = [        'email',        'social',        'search',        'referral',        'paid',    ]    random_utm_source = [        'google',        'bing',        'duckduckgo',        'facebook',        'twitter',        'instagram',        'linkedin',        'reddit',    ]    random_utm_campaign = [        '2022 search',        '2022 email',        '2022 social',        '2022 referral',    ]    # Generate some session_start events    for user_id in user_ids:        page = random.choice(url_paths)        screen = random.choice(screen_sizes)        location = random.choice(random_locations)        event = Event.objects.create(            property=property,            event='session_start',            data={                'user_id': user_id,                'url': page['path'],                'title': page['title'],                'referrer': random.choice(referrer_urls),                'user_agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36',                'screen_width': screen['width'],                'screen_height': screen['height'],                'platform': random.choice(platforms),                'device': random.choice(['Desktop', 'Tablet', 'Mobile']),                'region': location['region'],            },        )        Event.objects.filter(id=event.id).update(created_at=timezone.now() - timezone.timedelta(days=random.randint(0, 56)))    # Generate some session_start events from random users to drive down user engagement    for _ in range(random.randint(50, 100)):        page = random.choice(url_paths)        screen = random.choice(screen_sizes)        event = Event.objects.create(            property=property,            event='session_start',            data={                'user_id': random.randint(100000000, 999999999),                'url': page['path'],                'title': page['title'],                'referrer': random.choice(referrer_urls),                'user_agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36',                'screen_width': screen['width'],                'screen_height': screen['height'],                'platform': random.choice(platforms),                'device': random.choice(['Desktop', 'Tablet', 'Mobile']),                'browser': random.choice(['Chrome', 'Firefox', 'Safari', 'Edge', 'Opera']),            },        )        Event.objects.filter(id=event.id).update(created_at=timezone.now() - timezone.timedelta(days=random.randint(0, 56)))    # Generate some page_view events    for _ in range(random.randint(300, 600)):        page = random.choice(url_paths)        event = Event.objects.create(            property=property,            event='page_view',            data={                'user_id': random.choice(user_ids),                'url': page['path'],                'title': page['title'],                'utm_medium': random.choice(random_utm_medium),                'utm_source': random.choice(random_utm_source),                'utm_campaign': random.choice(random_utm_campaign),            },        )        Event.objects.filter(id=event.id).update(created_at=timezone.now() - timezone.timedelta(days=random.randint(0, 56)))    # Generate some scroll events    for _ in range(random.randint(300, 600)):        page = random.choice(url_paths)        event = Event.objects.create(            property=property,            event='scroll',            data={                'user_id': random.choice(user_ids),                'url': page['path'],                'title': page['title'],            },        )        Event.objects.filter(id=event.id).update(created_at=timezone.now() - timezone.timedelta(days=random.randint(0, 56)))    # Generate some click events    for _ in range(random.randint(300, 600)):        page = random.choice(url_paths)        screen = random.choice(screen_sizes)        event = Event.objects.create(            property=property,            event='click',            data={                'user_id': random.choice(user_ids),                'url': page['path'],                'title': page['title'],                'x': random.randint(0, screen['width']),                'y': random.randint(0, screen['height']),                'target': random.choice(['a', 'button', 'input', 'link', 'select', 'textarea']),                'text': random.choice(['', 'Hello World', 'This is a test']),            },        )        Event.objects.filter(id=event.id).update(created_at=timezone.now() - timezone.timedelta(days=random.randint(0, 56)))    # Generate some page_leave events    for _ in range(random.randint(300, 600)):        page = random.choice(url_paths)        event = Event.objects.create(            property=property,            event='page_leave',            data={                'user_id': random.choice(user_ids),                'url': page['path'],                'title': page['title'],                'time_on_page': random.randint(0, 100000),            },        )        Event.objects.filter(id=event.id).update(created_at=timezone.now() - timezone.timedelta(days=random.randint(0, 56)))    # Generate some custom events    for _ in range(random.randint(300, 600)):        event = Event.objects.create(            property=property,            event=random.choice(custom_events),            data={                'user_id': random.choice(user_ids),            },        )        Event.objects.filter(id=event.id).update(created_at=timezone.now() - timezone.timedelta(days=random.randint(0, 56)))class Migration(migrations.Migration):    dependencies = [        ('accounts', '0002_initial_data'),        ('properties', '0001_initial'),    ]    operations = [        migrations.RunPython(create_proprium),        migrations.RunPython(create_exemplar),    ]
deleted properties/migrations/0003_alter_property_custom_cards.py
@@ -1,18 +0,0 @@# Generated by Django 4.0.5 on 2022-06-23 23:11from django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [        ('properties', '0002_initial_data'),    ]    operations = [        migrations.AlterField(            model_name='property',            name='custom_cards',            field=models.JSONField(blank=True, default=list, null=True),        ),    ]
deleted properties/migrations/0004_event_properties__propert_9b5576_idx_and_more.py
@@ -1,21 +0,0 @@# Generated by Django 6.0.4 on 2026-04-17 03:58from django.db import migrations, modelsclass Migration(migrations.Migration):    dependencies = [        ('properties', '0003_alter_property_custom_cards'),    ]    operations = [        migrations.AddIndex(            model_name='event',            index=models.Index(fields=['property', 'created_at'], name='properties__propert_9b5576_idx'),        ),        migrations.AddIndex(            model_name='event',            index=models.Index(fields=['property', 'event', 'created_at'], name='properties__propert_244021_idx'),        ),    ]
deleted properties/migrations/__init__.py
deleted properties/models.py
@@ -1,90 +0,0 @@import uuidfrom django.db import modelsfrom django.utils import timezonefrom django.contrib.auth import get_user_modelUser = get_user_model()class Property(models.Model):    """    A site that we attach all our analytics hits to and connect up to a user.    """    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='properties')    name = models.CharField(max_length=255)    custom_cards = models.JSONField(default=list, blank=True, null=True)    created_at = models.DateTimeField(auto_now_add=True, editable=False)    updated_at = models.DateTimeField(auto_now=True, editable=False)    is_protected = models.BooleanField(default=False, editable=False)    is_public = models.BooleanField(default=False, editable=False)    class Meta:        verbose_name = 'Property'        verbose_name_plural = 'Properties'    def __str__(self):        return self.name    @property    def is_active(self):        """        Returns True if we've recieved any events for this property in the last        7 days.        """        return self.events.filter(created_at__gte=timezone.now() - timezone.timedelta(days=7)).exists()    @property    def total_events(self):        return self.events.count()    @property    def total_session_starts(self):        return self.events.filter(event="session_start").count()    @property    def total_page_views(self):        return self.events.filter(event="page_view").count()    @property    def total_clicks(self):        return self.events.filter(event="click").count()    @property    def total_scrolls(self):        return self.events.filter(event="scroll").count()class Event(models.Model):    """    An event that is sent by a site that we want to collect. The most basic of    events is a "page_view" event. All events can have a variety of key-value    pairs sent along with them that we store in a JSONField.    As an example a "page_view" may contain the following key-value pairs:    - url: The URL of the page that was viewed    - title: The title of the page that was viewed    - referrer: The URL of the page that referred the user to the page that was viewed    - user_agent: The user agent of the user that viewed the page    - screen_width: The width of the screen of the user that viewed the page    - screen_height: The height of the screen of the user that viewed the page    Users are free to send any event with any key-value pairs they want.    """    created_at = models.DateTimeField(auto_now_add=True, editable=False)    property = models.ForeignKey(Property, on_delete=models.CASCADE, related_name="events", editable=False)    event = models.CharField(max_length=255, editable=False)    data = models.JSONField(editable=False)    def __str__(self):        return self.event    class Meta:        indexes = [            models.Index(fields=['created_at']),            models.Index(fields=['property', 'created_at']),            models.Index(fields=['property', 'event', 'created_at']),        ]
deleted properties/queries.py
@@ -1,359 +0,0 @@from django.db.models import Avg, Count, FloatField, Qfrom django.db.models.functions import Cast, TruncDatefrom django.utils import timezonefrom .constants import BUILT_IN_EVENTSdef human_events(events):    """Exclude bot-tagged events from a queryset.    SQLite's JSON NOT-EQUAL doesn't match rows where the key is missing, so    `exclude(data__is_bot=True)` drops everything. `has_key` matches only rows    that contain the key — and we only ever set is_bot when the UA is a bot.    """    return events.exclude(data__has_key="is_bot")def total_live_users(property_obj):    """    Total unique user_ids seen in the last 30 minutes.    """    return (        human_events(property_obj.events)        .filter(created_at__gte=timezone.now() - timezone.timedelta(minutes=30))        .exclude(data__user_id__isnull=True)        .values("data__user_id")        .distinct()        .count()    )# Cap time-on-page to filter out idle-tab outliers (left open for hours).# < 1s is likely bot/instant-exit; > 30min is almost certainly idle.TIME_ON_PAGE_MIN_S = 1TIME_ON_PAGE_MAX_S = 30 * 60def _pct_change(current, previous):    if not previous:        return 0    return round((current - previous) / previous * 100)def _event_counts(qs):    """Single-aggregate pass for the five built-in event counts + total."""    return qs.aggregate(        session_start=Count("id", filter=Q(event="session_start")),        page_view=Count("id", filter=Q(event="page_view")),        click=Count("id", filter=Q(event="click")),        scroll=Count("id", filter=Q(event="scroll")),        total=Count("id"),    )def _engaged_users(qs, session_starts):    if not session_starts:        return 0    engaged = (        qs.exclude(data__user_id__isnull=True)        .values("data__user_id")        .annotate(c=Count("id"))        .filter(c__gte=10)        .count()    )    return round(engaged / session_starts * 100, 2)def _avg_time_on_page(qs):    try:        avg = (            qs.filter(event="page_leave")            .annotate(time_on_page_s=Cast("data__time_on_page", FloatField()) / 1000)            .filter(                time_on_page_s__gte=TIME_ON_PAGE_MIN_S,                time_on_page_s__lte=TIME_ON_PAGE_MAX_S,            )            .aggregate(avg=Avg("time_on_page_s"))["avg"]        )        return round(avg, 2) if avg is not None else 0    except TypeError:        return 0def standard_event_cards(events_filtered, events_filtered_prev):    """Standard metric cards. Two aggregate queries plus engagement/time-on-page helpers."""    cur = _event_counts(events_filtered)    prev = _event_counts(events_filtered_prev)    cards = [        {            "name": "Total session starts",            "value": cur["session_start"],            "percent_change": _pct_change(cur["session_start"], prev["session_start"]),            "help_text": "Unique users visiting your site for your selected date range.",        },        {            "name": "Total page views",            "value": cur["page_view"],            "percent_change": _pct_change(cur["page_view"], prev["page_view"]),            "help_text": "Total pages viewed for your selected date range.",        },        {            "name": "Total clicks",            "value": cur["click"],            "percent_change": _pct_change(cur["click"], prev["click"]),            "help_text": "Total clicks users made on all your pages for your selected date range.",        },        {            "name": "Total scrolls",            "value": cur["scroll"],            "percent_change": _pct_change(cur["scroll"], prev["scroll"]),            "help_text": "Total scrolls users made on all your pages for your selected date range.",        },        {            "name": "Total events",            "value": cur["total"],            "percent_change": _pct_change(cur["total"], prev["total"]),            "help_text": "All events for your selected date range, including custom events.",        },    ]    engagement_cur = _engaged_users(events_filtered, cur["session_start"])    engagement_prev = _engaged_users(events_filtered_prev, prev["session_start"])    cards.append({        "name": "Total user engagement",        "value": f"{engagement_cur}%",        "percent_change": _pct_change(engagement_cur, engagement_prev),        "help_text": "An engaged user is a user more than 10 events collected for your selected date range.",    })    time_cur = _avg_time_on_page(events_filtered)    time_prev = _avg_time_on_page(events_filtered_prev)    cards.append({        "name": "Avg. time on page",        "value": f"{time_cur}s",        "percent_change": _pct_change(time_cur, time_prev),        "help_text": "Average time a user spends on each page. Sessions over 30 minutes are excluded as idle.",    })    return cardsdef custom_event_cards(property_obj, events_filtered, events_filtered_prev):    """Returns (cards, custom_events). Custom events are non-built-in event names."""    custom_events = list(        human_events(property_obj.events)        .exclude(event__in=BUILT_IN_EVENTS)        .values("event")        .distinct()        .order_by("event")    )    active_names = {c["event"] for c in property_obj.custom_cards if c.get("value") is True}    for ce in custom_events:        ce["active"] = ce["event"] in active_names    if not active_names:        return [], custom_events    cur = events_filtered.filter(event__in=active_names).values("event").annotate(c=Count("id"))    prev = events_filtered_prev.filter(event__in=active_names).values("event").annotate(c=Count("id"))    cur_map = {r["event"]: r["c"] for r in cur}    prev_map = {r["event"]: r["c"] for r in prev}    cards = []    for ce in custom_events:        if ce["event"] not in active_names:            continue        v = cur_map.get(ce["event"], 0)        p = prev_map.get(ce["event"], 0)        cards.append({            "name": ce["event"],            "value": v,            "percent_change": _pct_change(v, p),        })    return cards, custom_eventsdef events_graph(events_filtered, date_end_obj, date_range):    """    One GROUP BY query for daily counts, then bucket into days/weeks/months    in Python. Buckets step backwards from date_end.    """    rows = (        events_filtered.annotate(day=TruncDate("created_at"))        .values("day")        .annotate(count=Count("id"))    )    by_day = {r["day"]: r["count"] for r in rows if r["day"]}    end_date = date_end_obj.date()    def bucket_sum(start_date, days):        return sum(            by_day.get(start_date + timezone.timedelta(days=j), 0)            for j in range(days)        )    if date_range <= 28:        points = [            {                "label": end_date - timezone.timedelta(days=i),                "count": by_day.get(end_date - timezone.timedelta(days=i), 0),            }            for i in range(date_range)        ]    elif date_range <= 6 * 28:        points = [            {                "label": end_date - timezone.timedelta(days=7 * w),                "count": bucket_sum(end_date - timezone.timedelta(days=7 * w), 7),            }            for w in range(date_range // 7)        ]    else:        points = [            {                "label": end_date - timezone.timedelta(days=28 * m),                "count": bucket_sum(end_date - timezone.timedelta(days=28 * m), 28),            }            for m in range(date_range // 28)        ]    points.sort(key=lambda k: k["label"])    for p in points:        p["label"] = p["label"].strftime("%b %-d")    return pointsdef _top_by_key(qs, key, limit=10, event=None):    """Generic top-N list for a JSON key with count."""    if event is not None:        qs = qs.filter(event=event)    rows = (        qs.exclude(**{f"{key}__isnull": True})        .exclude(**{key: ""})        .values(key)        .annotate(count=Count("id"))        .order_by("-count")[:limit]    )    return [{"label": r[key], "count": r["count"]} for r in rows]def events_by_screen_size(events_filtered, limit=7):    rows = (        events_filtered.filter(event="session_start")        .exclude(data__screen_width__isnull=True)        .values("data__screen_width", "data__screen_height")        .annotate(count=Count("id"))        .order_by("-count")[:limit]    )    return [        {            "label": f"{r['data__screen_width']}x{r['data__screen_height']}",            "count": r["count"],        }        for r in rows    ]def events_by_device(events_filtered, limit=7):    return _top_by_key(events_filtered, "data__device", limit, event="session_start")def events_by_browser(events_filtered, limit=7):    return _top_by_key(events_filtered, "data__browser", limit, event="session_start")def events_by_platform(events_filtered, limit=7):    return _top_by_key(events_filtered, "data__platform", limit, event="session_start")def events_by_page_url(events_filtered, limit=10):    return _top_by_key(events_filtered, "data__url", limit)def page_views_by_page_url(events_filtered, limit=10):    return _top_by_key(events_filtered, "data__url", limit, event="page_view")def events_by_custom_event(events_filtered, limit=10):    rows = (        events_filtered.exclude(event__in=BUILT_IN_EVENTS)        .values("event")        .annotate(count=Count("id"))        .order_by("-count")[:limit]    )    return [{"label": r["event"], "count": r["count"]} for r in rows]def session_starts_by_referrer(events_filtered, limit=10):    return _top_by_key(events_filtered, "data__referrer", limit, event="session_start")def page_views_by_utm(events_filtered, field, limit=10):    return _top_by_key(events_filtered, f"data__utm_{field}", limit, event="page_view")def session_starts_by_country(events_filtered):    """Sessions grouped by ISO 3166-1 alpha-2 country code."""    rows = (        events_filtered.filter(event="session_start")        .exclude(data__country__isnull=True)        .values("data__country")        .annotate(count=Count("id"))    )    return {r["data__country"]: r["count"] for r in rows}def session_starts_by_country_region(events_filtered):    """    Sessions grouped first by country, then by region within that country.    Returned shape: {"US": {"CA": 42, "NY": 17}, "DE": {"BY": 9}, ...}.    Used by the world map for click-to-drill-down — the whole tree ships    with the dashboard so no extra request is needed when a country is    selected.    """    rows = (        events_filtered.filter(event="session_start")        .exclude(data__country__isnull=True)        .exclude(data__region__isnull=True)        .values("data__country", "data__region")        .annotate(count=Count("id"))    )    out = {}    for r in rows:        out.setdefault(r["data__country"], {})[r["data__region"]] = r["count"]    return outdef bot_traffic(events_all, limit=10):    """    Bot-only stats for the dashboard's bot card. Takes the unfiltered (bots    included) events queryset.    """    bots = events_all.filter(data__is_bot=True)    total = bots.count()    if not total:        return {"total": 0, "top_bots": [], "top_pages": []}    top_bots = list(        bots.exclude(data__bot_name__isnull=True)        .exclude(data__bot_name="")        .values("data__bot_name")        .annotate(count=Count("id"))        .order_by("-count")[:limit]    )    top_pages = list(        bots.exclude(data__url__isnull=True)        .exclude(data__url="")        .values("data__url")        .annotate(count=Count("id"))        .order_by("-count")[:limit]    )    return {        "total": total,        "top_bots": [{"label": r["data__bot_name"], "count": r["count"]} for r in top_bots],        "top_pages": [{"label": r["data__url"], "count": r["count"]} for r in top_pages],    }
deleted properties/urls.py
@@ -1,12 +0,0 @@from django.urls import pathfrom . import viewsurlpatterns = [    path('<uuid:property_id>/', views.property, name='property'),    path('<uuid:property_id>/delete/', views.property_delete, name='property_delete'),    path('<uuid:property_id>/cards/', views.adjust_custom_event_cards, name='adjust_custom_event_cards'),    path('<uuid:property_id>/is-public/', views.adjust_is_public_property, name='adjust_is_public_property'),    path('', views.properties, name='properties'),]
deleted properties/views.py
@@ -1,278 +0,0 @@import jsonimport uuidfrom django.conf import settingsfrom django.contrib import messagesfrom django.core.cache import cachefrom django.core.files.storage import default_storagefrom django.http import HttpResponse, JsonResponsefrom django.shortcuts import redirect, renderfrom django.template.loader import render_to_stringfrom django.utils import timezonefrom analytics.chromium import generate_pdf_from_htmlfrom . import queries as qfrom .forms import PropertyFormfrom .models import PropertyDASHBOARD_CACHE_TTL = 300  # secondsdef _chart_polyline(points, width=600, height=100, padding=4):    """SVG polyline points string for the events-over-time line chart on the    printed report. Toner-friendly: thin black stroke, no fill, no gradient."""    if not points:        return ""    counts = [p["count"] for p in points]    max_c = max(counts) or 1    n = len(counts)    usable_h = height - 2 * padding    if n == 1:        x = width / 2        y = height - padding - (counts[0] / max_c) * usable_h        return f"{x:.1f},{y:.1f}"    return " ".join(        f"{(i / (n - 1)) * width:.1f},{height - padding - (c / max_c) * usable_h:.1f}"        for i, c in enumerate(counts)    )def _breakdown_total(rows):    return sum(r["count"] for r in rows) or 1def properties(request):    if not request.user.is_authenticated:        return redirect("/")    if request.method == "POST":        form = PropertyForm(request.POST)        if form.is_valid():            new_property = form.save(commit=False)            new_property.user = request.user            new_property.save()            messages.success(request, "Property added successfully.")            return redirect("properties")    else:        form = PropertyForm()    properties = request.user.properties.all()    search = request.GET.get("q", None)    if search:        properties = properties.filter(name__icontains=search)    return render(        request,        "properties/properties.html",        {            "form": form,            "title": "Properties",            "description": "Manage your properties.",            "properties": properties,            "q": search,        },    )def property_delete(request, property_id):    if not request.user.is_authenticated:        return redirect("/")    try:        property_obj = request.user.properties.get(pk=property_id)    except Property.DoesNotExist:        return redirect("properties")    property_obj.delete()    messages.success(request, "Property deleted successfully.")    return redirect("properties")def adjust_custom_event_cards(request, property_id):    if not request.user.is_authenticated:        return redirect("/")    try:        property_obj = request.user.properties.get(pk=property_id)    except Property.DoesNotExist:        return redirect("properties")    if request.method == "POST":        custom_cards = json.loads(request.body.decode("utf-8"))        property_obj.custom_cards = custom_cards        property_obj.save()        return JsonResponse({"success": True})    return JsonResponse({"success": False})def adjust_is_public_property(request, property_id):    if not request.user.is_authenticated:        return redirect("/")    try:        property_obj = request.user.properties.get(pk=property_id)    except Property.DoesNotExist:        return redirect("properties")    if request.method == "POST":        property_obj.is_public = property_obj.is_public is False        property_obj.save()        return JsonResponse({"success": True})    return JsonResponse({"success": False})def _dashboard_context(property_obj, date_start_obj, date_end_obj, date_range, filter_url):    """    Heavy context built from DB queries. Cacheable — no request-specific state.    """    events_all = property_obj.events.filter(        created_at__gte=date_start_obj, created_at__lte=date_end_obj    )    if filter_url:        events_all = events_all.filter(data__url=filter_url)    events_filtered = q.human_events(events_all)    date_start_obj_prev = date_start_obj - timezone.timedelta(days=date_range)    date_end_obj_prev = date_end_obj - timezone.timedelta(days=date_range)    events_filtered_prev = q.human_events(        property_obj.events.filter(            created_at__gte=date_start_obj_prev, created_at__lte=date_end_obj_prev        )    )    if filter_url:        events_filtered_prev = events_filtered_prev.filter(data__url=filter_url)    event_cards = list(q.standard_event_cards(events_filtered, events_filtered_prev))    custom_cards, custom_events = q.custom_event_cards(        property_obj, events_filtered, events_filtered_prev    )    event_cards.extend(custom_cards)    return {        "event_cards": event_cards,        "custom_events": custom_events,        "total_events_graph": q.events_graph(events_filtered, date_end_obj, date_range),        "total_events_by_screen_size": q.events_by_screen_size(events_filtered),        "total_events_by_device": q.events_by_device(events_filtered),        "total_events_by_browser": q.events_by_browser(events_filtered),        "total_events_by_platform": q.events_by_platform(events_filtered),        "total_events_by_page_url": q.events_by_page_url(events_filtered),        "total_page_views_by_page_url": q.page_views_by_page_url(events_filtered),        "total_events_by_custom_event": q.events_by_custom_event(events_filtered),        "total_session_starts_by_referrer": q.session_starts_by_referrer(events_filtered),        "total_page_views_by_utm_medium": q.page_views_by_utm(events_filtered, "medium"),        "total_page_views_by_utm_source": q.page_views_by_utm(events_filtered, "source"),        "total_page_views_by_utm_campaign": q.page_views_by_utm(events_filtered, "campaign"),        "session_starts_by_country": q.session_starts_by_country(events_filtered),        "session_starts_by_country_region": q.session_starts_by_country_region(events_filtered),        "bot_traffic": q.bot_traffic(events_all),    }def property(request, property_id):    try:        property_obj = Property.objects.get(pk=property_id)    except Property.DoesNotExist:        return redirect("properties")    if not property_obj.is_public and property_obj.user != request.user:        return redirect("properties")    date_start = request.GET.get(        "date_start",        (timezone.now() - timezone.timedelta(days=28)).strftime("%Y-%m-%d"),    )    date_end = request.GET.get("date_end", timezone.now().strftime("%Y-%m-%d"))    date_range = request.GET.get("date_range", 28)    date_start_obj = timezone.make_aware(        timezone.datetime.strptime(date_start, "%Y-%m-%d"),        timezone.get_current_timezone(),    )    date_end_obj = timezone.make_aware(        timezone.datetime.strptime(date_end, "%Y-%m-%d")        + timezone.timedelta(hours=23, minutes=59, seconds=59),        timezone.get_current_timezone(),    )    if date_range == "custom":        date_range = (date_end_obj - date_start_obj).days    else:        date_range = int(date_range)    filter_url = request.GET.get("filter_url", None)    # Heavy DB work goes through a 5-min cache. Live users stays uncached so    # "live" actually means live. ?report bypasses the cache so exports match    # whatever the user sees in the dashboard right now.    # Include updated_at so custom-cards/visibility changes bust the cache.    ver = int(property_obj.updated_at.timestamp())    cache_key = (        f"dash:{property_obj.id}:{ver}:{date_start}:{date_end}:{date_range}:{filter_url or ''}"    )    if "report" in request.GET:        dashboard = _dashboard_context(property_obj, date_start_obj, date_end_obj, date_range, filter_url)    else:        dashboard = cache.get(cache_key)        if dashboard is None:            dashboard = _dashboard_context(property_obj, date_start_obj, date_end_obj, date_range, filter_url)            cache.set(cache_key, dashboard, DASHBOARD_CACHE_TTL)    context = {        "property": property_obj,        "title": property_obj.name,        "description": "Analytics for " + property_obj.name,        "BASE_URL": settings.BASE_URL,        "date_start": date_start,        "date_end": date_end,        "date_range": date_range,        "filter_url": filter_url,        "total_live_users": q.total_live_users(property_obj),        **dashboard,    }    if "report" in request.GET:        fmt = request.GET.get("report") or "pdf"        if fmt == "md":            md = render_to_string("properties/property_report.md", context)            response = HttpResponse(md, content_type="text/markdown; charset=utf-8")            response["Content-Disposition"] = f'inline; filename="{property_obj.name}.md"'            return response        if fmt == "pdf":            graph = context.get("total_events_graph") or []            peak = max(graph, key=lambda p: p["count"]) if graph else None            country_counts = context.get("session_starts_by_country") or {}            top_countries = sorted(                ({"label": code, "count": count} for code, count in country_counts.items()),                key=lambda r: r["count"],                reverse=True,            )[:10]            print_context = {                **context,                "chart_polyline": _chart_polyline(graph),                "chart_label_start": graph[0]["label"] if graph else "",                "chart_label_end": graph[-1]["label"] if graph else "",                "chart_peak_count": peak["count"] if peak else 0,                "chart_peak_label": peak["label"] if peak else "",                "breakdown_totals": {                    "device": _breakdown_total(context.get("total_events_by_device") or []),                    "browser": _breakdown_total(context.get("total_events_by_browser") or []),                    "platform": _breakdown_total(context.get("total_events_by_platform") or []),                    "screen_size": _breakdown_total(context.get("total_events_by_screen_size") or []),                },                "top_countries": top_countries,            }            html = render_to_string("properties/property_print.html", print_context)            filename = f"reports/{uuid.uuid4()}.pdf"            generate_pdf_from_html(html, filename)            with open(default_storage.path(filename), "rb") as pdf:                response = HttpResponse(pdf.read(), content_type="application/pdf")                response["Content-Disposition"] = "inline; filename=report.pdf"                return response    return render(request, "properties/property.html", context)
deleted pyproject.toml
@@ -1,15 +0,0 @@[project]name = "analytics"version = "0.0.1"requires-python = ">=3.12"dependencies = [    "django",    "dnspython",    "gunicorn",    "requests",    "tzdata",    "user-agents",    "uvicorn",    "whitenoise",    "geoip2",]
modified samplefiles/Caddyfile.sample
@@ -1,9 +1,7 @@# Caddyfile for analytics## I like to use Caddy Server for simple reverse proxies since it has built in# automatic HTTPS support. It is probably worth serving static files with# Caddy too but I've honestly been too lazy to implement that. I just use# whitenoise on Django for that.# automatic HTTPS support.{  servers {
@@ -18,7 +16,7 @@    Cache-Control "public, max-age=315360000"  }  header /media/* {  header /static_maps/* {    Cache-Control "public, max-age=315360000"  }
@@ -35,26 +33,6 @@}analytics.example.com {  handle /collect/ {    @options {      method OPTIONS    }    respond @options 204    header Access-Control-Allow-Origin *    header Access-Control-Allow-Methods *    header Access-Control-Allow-Headers *    header Access-Control-Max-Age 31536000  }  handle /media/* {    uri strip_prefix /media    file_server {      root /srv/data/analytics/media    }  }  reverse_proxy localhost:8000  import common
modified samplefiles/env.sample
@@ -1,4 +1,14 @@DJANGO_SETTINGS_MODULE=analytics.settings.productionDJANGO_SECRET_KEY=ADD_A_SUPER_SECRET_KEY_HEREDJANGO_BASE_URL=https://analytics.example.comPORT=8000# Single-operator dashboard password. Required.ANALYTICS_PASSWORD=change-me# Optional: 32+ random bytes used to sign the session cookie. If absent, the# cookie key is derived deterministically from ANALYTICS_PASSWORD (so changing# the password invalidates all existing sessions). Setting this explicitly is# preferred — `openssl rand -base64 64` works.# ANALYTICS_COOKIE_SECRET=# Used in templates for absolute URLs (sitemap, og tags, the embed snippet# shown on the dashboard). Keep it without a trailing slash.BASE_URL=https://analytics.example.com
modified samplefiles/post-receive.sample
@@ -1,19 +1,17 @@#!/bin/sh## For easy server deploys you can create add this post-receive hook to your# server in a bare repo somewhere like /srv/git/analytics/hooks/post-receive# and make a git clone in /srv/docker/analytics. This script will then run on# pushing updates to the server to build and deploy new docker images.# For easy server deploys add this post-receive hook to your server in a bare# repo somewhere like /srv/git/analytics/hooks/post-receive and make a git# clone in /srv/docker/analytics. This script runs on push to rebuild and# redeploy the docker container.while read oldrev newrev ref; do  if [ "$ref" = "refs/heads/master" ]; then    unset GIT_DIR  # unset GIT_DIR so that git pull works correctly    unset GIT_DIR    START_TIME=`date +%s`    cd /srv/docker/analytics    git pull    docker-compose up --build --detach    docker-compose run web python3 manage.py migrate --noinput    docker system prune --force    END_TIME=`date +%s`    echo Total build time: `expr $END_TIME - $START_TIME`s
added src/auth.rs
@@ -0,0 +1,117 @@use axum::{    extract::{Form, State},    http::StatusCode,    response::{Html, IntoResponse, Redirect, Response},};use chrono::{Datelike, Utc};use serde::Deserialize;use tower_cookies::{    cookie::{time::Duration, SameSite},    Cookie, Cookies,};use crate::AppState;const COOKIE_NAME: &str = "session";const SESSION_TTL_SECS: i64 = 30 * 24 * 60 * 60;#[derive(Debug, Deserialize)]pub struct LoginForm {    pub password: String,    #[serde(default)]    pub next: Option<String>,}pub fn is_authenticated(cookies: &Cookies, state: &AppState) -> bool {    let signed = cookies.signed(&state.cookie_key);    let Some(c) = signed.get(COOKIE_NAME) else { return false };    let value = c.value();    let Some((flag, exp_str)) = value.split_once(':') else { return false };    if flag != "1" {        return false;    }    let Ok(exp) = exp_str.parse::<i64>() else { return false };    Utc::now().timestamp() < exp}fn render_login(state: &AppState, error: Option<&str>) -> Result<Html<String>, StatusCode> {    let tmpl = state        .env        .get_template("registration/login.html")        .map_err(|e| {            tracing::error!("template registration/login.html: {e}");            StatusCode::INTERNAL_SERVER_ERROR        })?;    let body = tmpl        .render(minijinja::context! {            user => crate::templates::UserCtx::default(),            request => crate::templates::RequestCtx {                url: String::new(),                url_root: "/".to_string(),                base_url: String::new(),                path: "/login".to_string(),            },            now => minijinja::context! { year => chrono::Local::now().year() },            base_url => &state.config.base_url,            collector_id => state.config.proprium_id.map(|u| u.to_string()),            collector_server => &state.config.base_url,            messages => Vec::<()>::new(),            page => minijinja::context! {                title => "Log in",                description => "Log in to your dashboard.",            },            error => error,            next => "/properties",        })        .map_err(|e| {            tracing::error!("render login: {e}");            StatusCode::INTERNAL_SERVER_ERROR        })?;    Ok(Html(body))}pub async fn login_form(State(state): State<AppState>, cookies: Cookies) -> Response {    if is_authenticated(&cookies, &state) {        return Redirect::to("/properties").into_response();    }    match render_login(&state, None) {        Ok(html) => html.into_response(),        Err(e) => e.into_response(),    }}pub async fn login_submit(    State(state): State<AppState>,    cookies: Cookies,    Form(form): Form<LoginForm>,) -> Response {    if form.password != state.config.password {        let html = match render_login(&state, Some("Invalid password.")) {            Ok(h) => h,            Err(e) => return e.into_response(),        };        return (StatusCode::UNAUTHORIZED, html).into_response();    }    let exp = Utc::now().timestamp() + SESSION_TTL_SECS;    let value = format!("1:{exp}");    let cookie = Cookie::build((COOKIE_NAME, value))        .path("/")        .http_only(true)        .same_site(SameSite::Strict)        .max_age(Duration::seconds(SESSION_TTL_SECS))        .build();    cookies.signed(&state.cookie_key).add(cookie);    let next = form        .next        .filter(|n| n.starts_with('/') && !n.starts_with("//"))        .unwrap_or_else(|| "/properties".to_string());    Redirect::to(&next).into_response()}pub async fn logout(State(state): State<AppState>, cookies: Cookies) -> Redirect {    let signed = cookies.signed(&state.cookie_key);    if let Some(c) = signed.get(COOKIE_NAME) {        cookies.remove(c);    }    Redirect::to("/")}
added src/bin/seed.rs
@@ -0,0 +1,353 @@// Seeds a "Seed Test" property with realistic-looking fake events.//// Usage://   cargo run --bin seed                  # 500 sessions, last 30 days//   cargo run --bin seed -- 2000 60       # 2000 sessions, last 60 days//// Re-runs reuse the property and wipe its existing events first so the// dashboard URL stays stable.#[path = "../db.rs"]#[allow(dead_code)]mod db;use anyhow::Result;use chrono::Utc;use rand::prelude::*;use sqlx::{SqliteConnection, SqlitePool};use std::path::PathBuf;use uuid::Uuid;const PROPERTY_NAME: &str = "Seed Test";const URLS: &[(&str, &str, u32)] = &[    ("/", "Home", 40),    ("/about", "About", 10),    ("/pricing", "Pricing", 8),    ("/docs", "Documentation", 8),    ("/blog", "Blog", 5),    ("/blog/getting-started", "Getting Started", 5),    ("/blog/whats-new-in-v2", "What's New in v2", 4),    ("/blog/case-studies", "Case Studies", 3),    ("/contact", "Contact", 4),    ("/login", "Log In", 4),    ("/signup", "Sign Up", 4),    ("/dashboard", "Dashboard", 5),];const REFERRERS: &[(&str, u32)] = &[    ("", 50),    ("google.com", 20),    ("twitter.com", 5),    ("news.ycombinator.com", 3),    ("github.com", 3),    ("reddit.com", 4),    ("duckduckgo.com", 3),    ("bing.com", 3),    ("linkedin.com", 2),    ("producthunt.com", 2),    ("dev.to", 2),    ("medium.com", 1),];struct Agent {    ua: &'static str,    platform: &'static str,    browser: &'static str,    device: &'static str,    is_bot: bool,    bot_name: Option<&'static str>,    weight: u32,}const AGENTS: &[Agent] = &[    Agent { ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",            platform: "Windows", browser: "Chrome", device: "Desktop", is_bot: false, bot_name: None, weight: 25 },    Agent { ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",            platform: "Mac OS X", browser: "Chrome", device: "Desktop", is_bot: false, bot_name: None, weight: 15 },    Agent { ua: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36",            platform: "Android", browser: "Chrome Mobile", device: "Mobile", is_bot: false, bot_name: None, weight: 15 },    Agent { ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",            platform: "Mac OS X", browser: "Safari", device: "Desktop", is_bot: false, bot_name: None, weight: 10 },    Agent { ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1",            platform: "iOS", browser: "Mobile Safari", device: "Mobile", is_bot: false, bot_name: None, weight: 15 },    Agent { ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",            platform: "Windows", browser: "Firefox", device: "Desktop", is_bot: false, bot_name: None, weight: 5 },    Agent { ua: "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",            platform: "Ubuntu", browser: "Firefox", device: "Desktop", is_bot: false, bot_name: None, weight: 3 },    Agent { ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0",            platform: "Windows", browser: "Edge", device: "Desktop", is_bot: false, bot_name: None, weight: 8 },    Agent { ua: "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",            platform: "", browser: "", device: "", is_bot: true, bot_name: Some("Googlebot"), weight: 1 },    Agent { ua: "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)",            platform: "", browser: "", device: "", is_bot: true, bot_name: Some("bingbot"), weight: 1 },    Agent { ua: "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)",            platform: "", browser: "", device: "", is_bot: true, bot_name: Some("facebookexternalhit"), weight: 1 },];struct GeoRow {    country: &'static str,    region: &'static str,    city: &'static str,    lat: f64,    lon: f64,    weight: u32,}const GEO: &[GeoRow] = &[    GeoRow { country: "US", region: "New York",         city: "New York",      lat: 40.7128, lon:  -74.0060, weight: 15 },    GeoRow { country: "US", region: "California",       city: "Los Angeles",   lat: 34.0522, lon: -118.2437, weight: 10 },    GeoRow { country: "US", region: "California",       city: "San Francisco", lat: 37.7749, lon: -122.4194, weight:  8 },    GeoRow { country: "US", region: "Illinois",         city: "Chicago",       lat: 41.8781, lon:  -87.6298, weight:  5 },    GeoRow { country: "US", region: "Texas",            city: "Austin",        lat: 30.2672, lon:  -97.7431, weight:  5 },    GeoRow { country: "GB", region: "England",          city: "London",        lat: 51.5074, lon:   -0.1278, weight:  8 },    GeoRow { country: "GB", region: "England",          city: "Manchester",    lat: 53.4808, lon:   -2.2426, weight:  2 },    GeoRow { country: "DE", region: "Berlin",           city: "Berlin",        lat: 52.5200, lon:   13.4050, weight:  5 },    GeoRow { country: "DE", region: "Bavaria",          city: "Munich",        lat: 48.1351, lon:   11.5820, weight:  3 },    GeoRow { country: "FR", region: "Île-de-France",    city: "Paris",         lat: 48.8566, lon:    2.3522, weight:  5 },    GeoRow { country: "CA", region: "Ontario",          city: "Toronto",       lat: 43.6532, lon:  -79.3832, weight:  4 },    GeoRow { country: "CA", region: "British Columbia", city: "Vancouver",     lat: 49.2827, lon: -123.1207, weight:  2 },    GeoRow { country: "AU", region: "New South Wales",  city: "Sydney",        lat: -33.8688, lon: 151.2093, weight:  3 },    GeoRow { country: "AU", region: "Victoria",         city: "Melbourne",     lat: -37.8136, lon: 144.9631, weight:  2 },    GeoRow { country: "JP", region: "Tokyo",            city: "Tokyo",         lat: 35.6762, lon:  139.6503, weight:  4 },    GeoRow { country: "BR", region: "São Paulo",        city: "São Paulo",     lat: -23.5505, lon: -46.6333, weight:  3 },    GeoRow { country: "IN", region: "Maharashtra",      city: "Mumbai",        lat: 19.0760, lon:   72.8777, weight:  3 },    GeoRow { country: "IN", region: "Karnataka",        city: "Bangalore",     lat: 12.9716, lon:   77.5946, weight:  3 },    GeoRow { country: "NL", region: "North Holland",    city: "Amsterdam",     lat: 52.3676, lon:    4.9041, weight:  3 },    GeoRow { country: "ES", region: "Madrid",           city: "Madrid",        lat: 40.4168, lon:   -3.7038, weight:  2 },    GeoRow { country: "IT", region: "Lazio",            city: "Rome",          lat: 41.9028, lon:   12.4964, weight:  2 },    GeoRow { country: "MX", region: "Mexico City",      city: "Mexico City",   lat: 19.4326, lon:  -99.1332, weight:  2 },    GeoRow { country: "KR", region: "Seoul",            city: "Seoul",         lat: 37.5665, lon:  126.9780, weight:  2 },    GeoRow { country: "SE", region: "Stockholm",        city: "Stockholm",     lat: 59.3293, lon:   18.0686, weight:  2 },    GeoRow { country: "PL", region: "Mazovia",          city: "Warsaw",        lat: 52.2297, lon:   21.0122, weight:  2 },    GeoRow { country: "TR", region: "Istanbul",         city: "Istanbul",      lat: 41.0082, lon:   28.9784, weight:  2 },    GeoRow { country: "ZA", region: "Gauteng",          city: "Johannesburg",  lat: -26.2041, lon:  28.0473, weight:  1 },];const SCREENS_DESKTOP: &[(i64, i64)] = &[    (1920, 1080), (1366, 768), (1440, 900), (1536, 864), (1680, 1050), (2560, 1440),];const SCREENS_MOBILE: &[(i64, i64)] = &[    (390, 844), (414, 896), (375, 667), (360, 800), (412, 915), (393, 851),];const UTM_SOURCES:   &[&str] = &["google", "twitter", "hn", "newsletter", "github", "producthunt"];const UTM_MEDIUMS:   &[&str] = &["cpc", "social", "email", "referral", "organic"];const UTM_CAMPAIGNS: &[&str] = &["launch-2026", "spring-promo", "blog-feature", "rebrand", "retarget"];fn weighted<'a, T>(rng: &mut impl Rng, items: &'a [T], weight: impl Fn(&T) -> u32) -> &'a T {    let total: u32 = items.iter().map(&weight).sum();    let mut pick = rng.gen_range(0..total);    for it in items {        let w = weight(it);        if pick < w {            return it;        }        pick -= w;    }    items.last().unwrap()}#[tokio::main]async fn main() -> Result<()> {    let _ = dotenvy::dotenv();    let args: Vec<String> = std::env::args().collect();    let sessions: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(500);    let days: i64       = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(30);    let data_dir = std::env::var("ANALYTICS_DATA_DIR")        .map(PathBuf::from)        .unwrap_or_else(|_| PathBuf::from("./data"));    std::fs::create_dir_all(&data_dir)?;    let pool = db::init(&data_dir).await?;    let property_id = ensure_property(&pool, PROPERTY_NAME).await?;    let pid_bytes = property_id.as_bytes().to_vec();    sqlx::query("DELETE FROM events WHERE property_id = ?")        .bind(&pid_bytes)        .execute(&pool)        .await?;    sqlx::query("DELETE FROM bot_events WHERE property_id = ?")        .bind(&pid_bytes)        .execute(&pool)        .await?;    let total = generate(&pool, &pid_bytes, sessions, days).await?;    println!("Seeded {} sessions ({} events) into property '{}' ({})", sessions, total, PROPERTY_NAME, property_id);    println!("Dashboard: http://localhost:8000/{}", property_id);    Ok(())}async fn ensure_property(pool: &SqlitePool, name: &str) -> Result<Uuid> {    let existing: Option<(Vec<u8>,)> = sqlx::query_as("SELECT id FROM properties WHERE name = ?")        .bind(name)        .fetch_optional(pool)        .await?;    if let Some((bytes,)) = existing {        return Ok(Uuid::from_slice(&bytes)?);    }    let id = Uuid::new_v4();    let now = Utc::now().timestamp_millis();    sqlx::query(        "INSERT INTO properties (id, name, custom_cards, is_protected, is_public, created_at, updated_at) \         VALUES (?, ?, '[]', 0, 0, ?, ?)",    )    .bind(id.as_bytes().to_vec())    .bind(name)    .bind(now)    .bind(now)    .execute(pool)    .await?;    Ok(id)}async fn generate(pool: &SqlitePool, pid: &[u8], sessions: usize, days: i64) -> Result<u64> {    let mut rng = thread_rng();    let now = Utc::now().timestamp_millis();    let window_ms: i64 = days * 24 * 60 * 60 * 1000;    let mut total = 0u64;    let mut tx = pool.begin().await?;    for _ in 0..sessions {        let agent = weighted(&mut rng, AGENTS, |a| a.weight);        let geo = weighted(&mut rng, GEO, |g| g.weight);        let referrer_str = weighted(&mut rng, REFERRERS, |r| r.1).0;        let referrer = if referrer_str.is_empty() { None } else { Some(referrer_str) };        let user_id = format!("{}", rng.gen_range(100_000_000u64..999_999_999u64));        let session_start = now - rng.gen_range(0..window_ms);        let (sw, sh) = if agent.device == "Mobile" {            *SCREENS_MOBILE.choose(&mut rng).unwrap()        } else {            *SCREENS_DESKTOP.choose(&mut rng).unwrap()        };        let (utm_source, utm_medium, utm_campaign) = if rng.gen_bool(0.3) {            (                Some(*UTM_SOURCES.choose(&mut rng).unwrap()),                Some(*UTM_MEDIUMS.choose(&mut rng).unwrap()),                Some(*UTM_CAMPAIGNS.choose(&mut rng).unwrap()),            )        } else {            (None, None, None)        };        if agent.is_bot {            let url_pick = weighted(&mut rng, URLS, |u| u.2);            sqlx::query(                "INSERT INTO bot_events (property_id, event, created_at, bot_name, url, user_agent, country, extra) \                 VALUES (?,?,?,?,?,?,?,'{}')",            )            .bind(pid)            .bind("page_view")            .bind(session_start)            .bind(agent.bot_name)            .bind(url_pick.0)            .bind(agent.ua)            .bind(geo.country)            .execute(&mut *tx)            .await?;            total += 1;            continue;        }        let page_count = rng.gen_range(1..=8usize);        let mut t = session_start;        let mut url_pick = weighted(&mut rng, URLS, |u| u.2);        insert_human(&mut tx, pid, "session_start", t, &user_id, url_pick.0, url_pick.1,                     referrer, agent, sw, sh, geo, utm_source, utm_medium, utm_campaign, None).await?;        total += 1;        for i in 0..page_count {            let time_on_page = rng.gen_range(2_000i64..120_000i64);            let pv_referrer = if i == 0 { referrer } else { None };            insert_human(&mut tx, pid, "page_view", t, &user_id, url_pick.0, url_pick.1,                         pv_referrer, agent, sw, sh, geo, utm_source, utm_medium, utm_campaign, None).await?;            total += 1;            if rng.gen_bool(0.4) {                let click_offset = rng.gen_range(500..time_on_page.max(1001));                insert_human(&mut tx, pid, "click", t + click_offset, &user_id, url_pick.0, url_pick.1,                             None, agent, sw, sh, geo, None, None, None, None).await?;                total += 1;            }            insert_human(&mut tx, pid, "page_leave", t + time_on_page, &user_id, url_pick.0, url_pick.1,                         None, agent, sw, sh, geo, None, None, None, Some(time_on_page)).await?;            total += 1;            t += time_on_page + rng.gen_range(500..3000);            if i + 1 < page_count {                url_pick = weighted(&mut rng, URLS, |u| u.2);            }        }    }    tx.commit().await?;    Ok(total)}#[allow(clippy::too_many_arguments)]async fn insert_human(    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,    pid: &[u8],    event: &str,    created_at: i64,    user_id: &str,    url: &str,    title: &str,    referrer: Option<&str>,    agent: &Agent,    screen_w: i64,    screen_h: i64,    geo: &GeoRow,    utm_source: Option<&str>,    utm_medium: Option<&str>,    utm_campaign: Option<&str>,    time_on_page_ms: Option<i64>,) -> Result<()> {    let conn: &mut SqliteConnection = &mut *tx;    sqlx::query(        "INSERT INTO events (\            property_id, event, created_at, user_id, url, title, referrer, user_agent, \            platform, browser, device, screen_width, screen_height, country, region, city, \            lat, lon, utm_source, utm_medium, utm_campaign, time_on_page_ms, extra\         ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'{}')",    )    .bind(pid)    .bind(event)    .bind(created_at)    .bind(user_id)    .bind(url)    .bind(title)    .bind(referrer)    .bind(agent.ua)    .bind(agent.platform)    .bind(agent.browser)    .bind(agent.device)    .bind(screen_w)    .bind(screen_h)    .bind(geo.country)    .bind(geo.region)    .bind(geo.city)    .bind(geo.lat)    .bind(geo.lon)    .bind(utm_source)    .bind(utm_medium)    .bind(utm_campaign)    .bind(time_on_page_ms)    .execute(conn)    .await?;    Ok(())}
added src/cache.rs
@@ -0,0 +1,35 @@use moka::future::Cache;use std::sync::Arc;use std::time::Duration;pub const TTL: Duration = Duration::from_secs(300);#[derive(Clone)]pub struct DashboardCache {    inner: Cache<String, Arc<serde_json::Value>>,}impl DashboardCache {    pub fn new() -> Self {        Self {            inner: Cache::builder()                .max_capacity(256)                .time_to_live(TTL)                .build(),        }    }    pub async fn get(&self, key: &str) -> Option<Arc<serde_json::Value>> {        self.inner.get(key).await    }    pub async fn insert(&self, key: String, value: Arc<serde_json::Value>) {        self.inner.insert(key, value).await;    }}impl Default for DashboardCache {    fn default() -> Self {        Self::new()    }}
added src/collector.rs
@@ -0,0 +1,276 @@use axum::{    extract::State,    http::{header, HeaderMap, StatusCode},    response::{IntoResponse, Response},};use serde::Deserialize;use serde_json::Value;use std::net::IpAddr;use uuid::Uuid;use crate::AppState;#[derive(Debug, Deserialize)]struct CollectBody {    #[serde(rename = "collectorId", alias = "collector_id")]    collector_id: Option<String>,    event: Option<String>,    #[serde(default)]    data: Value,}pub async fn options(headers: HeaderMap) -> Response {    let mut h = HeaderMap::new();    let allow_origin = headers        .get("origin")        .and_then(|v| v.to_str().ok())        .unwrap_or("*")        .to_string();    let allow_headers = headers        .get("access-control-request-headers")        .and_then(|v| v.to_str().ok())        .unwrap_or("Content-Type")        .to_string();    h.insert("allow", "OPTIONS, POST".parse().unwrap());    h.insert("access-control-allow-methods", "OPTIONS, POST".parse().unwrap());    h.insert("access-control-allow-headers", allow_headers.parse().unwrap());    h.insert("access-control-allow-origin", allow_origin.parse().unwrap());    (StatusCode::NO_CONTENT, h).into_response()}pub async fn collect(    State(state): State<AppState>,    headers: HeaderMap,    body: String,) -> Response {    if body.is_empty() {        return StatusCode::BAD_REQUEST.into_response();    }    let parsed: CollectBody = match serde_json::from_str(&body) {        Ok(v) => v,        Err(_) => return StatusCode::BAD_REQUEST.into_response(),    };    let Some(collector_id) = parsed.collector_id.as_deref() else {        return StatusCode::BAD_REQUEST.into_response();    };    let Some(event_name) = parsed.event.as_deref() else {        return StatusCode::BAD_REQUEST.into_response();    };    let Ok(property_id) = Uuid::parse_str(collector_id) else {        return StatusCode::BAD_REQUEST.into_response();    };    // Confirm property exists.    let exists: Option<(Vec<u8>,)> =        sqlx::query_as("SELECT id FROM properties WHERE id = ?")            .bind(property_id.as_bytes().to_vec())            .fetch_optional(&state.pool)            .await            .unwrap_or(None);    if exists.is_none() {        return StatusCode::NOT_FOUND.into_response();    }    let mut data = if parsed.data.is_object() {        parsed.data.as_object().unwrap().clone()    } else {        serde_json::Map::new()    };    // Normalize referrer to bare hostname.    if let Some(r) = data.get("referrer").and_then(|v| v.as_str()) {        let host = r.split("://").last().unwrap_or(r);        let host = host.split('/').next().unwrap_or("");        let host = host.to_ascii_lowercase().trim_start_matches("www.").to_string();        data.insert("referrer".to_string(), Value::String(host));    }    // GeoIP enrichment for session_start.    if event_name == "session_start" {        if let Some(ip) = client_ip(&headers) {            if !ip.is_loopback() {                if let Some(g) = state.geoip.lookup(ip) {                    if let Some(c) = g.country {                        data.insert("country".to_string(), Value::String(c));                    }                    if let Some(r) = g.region {                        data.insert("region".to_string(), Value::String(r));                    }                    if let Some(c) = g.city {                        data.insert("city".to_string(), Value::String(c));                    }                    if let (Some(lat), Some(lon)) = (g.lat, g.lon) {                        data.insert(                            "loc".to_string(),                            Value::Array(vec![                                serde_json::json!(lat),                                serde_json::json!(lon),                            ]),                        );                    }                }            }        }    }    // UA parsing.    let ua_string = data        .get("user_agent")        .and_then(|v| v.as_str())        .map(|s| s.to_string())        .or_else(|| {            headers                .get(header::USER_AGENT)                .and_then(|v| v.to_str().ok())                .map(|s| s.to_string())        });    if let Some(ua) = &ua_string {        let parsed_ua = state.ua.parse(ua);        if let Some(p) = parsed_ua.platform.clone() {            data.insert("platform".to_string(), Value::String(p));        }        if let Some(b) = parsed_ua.browser.clone() {            data.insert("browser".to_string(), Value::String(b));        }        if let Some(d) = parsed_ua.device.clone() {            data.insert("device".to_string(), Value::String(d));        }        if parsed_ua.is_bot {            data.insert("is_bot".to_string(), Value::Bool(true));            if let Some(name) = parsed_ua.bot_name.clone() {                data.insert("bot_name".to_string(), Value::String(name));            }            // Bot routing: write to bot_events instead of events.            let now = chrono::Utc::now().timestamp_millis();            let extra = serde_json::Value::Object(data.clone()).to_string();            let _ = sqlx::query(                "INSERT INTO bot_events (property_id, event, created_at, bot_name, url, user_agent, country, extra) \                 VALUES (?, ?, ?, ?, ?, ?, ?, ?)",            )            .bind(property_id.as_bytes().to_vec())            .bind(event_name)            .bind(now)            .bind(parsed_ua.bot_name.as_deref())            .bind(data.get("url").and_then(|v| v.as_str()))            .bind(ua.as_str())            .bind(data.get("country").and_then(|v| v.as_str()))            .bind(extra)            .execute(&state.pool)            .await;            return cors_204(&headers);        }    }    // Human path: extract hot fields, leave the rest in extra.    let now = chrono::Utc::now().timestamp_millis();    let take_str = |key: &str, m: &mut serde_json::Map<String, Value>| -> Option<String> {        m.remove(key)            .and_then(|v| v.as_str().map(|s| s.to_string()).or_else(|| Some(v.to_string())))            .filter(|s| !s.is_empty())    };    let take_i64 = |key: &str, m: &mut serde_json::Map<String, Value>| -> Option<i64> {        m.remove(key).and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))    };    let take_f64 = |key: &str, m: &mut serde_json::Map<String, Value>| -> Option<f64> {        m.remove(key).and_then(|v| v.as_f64())    };    let user_id = take_str("user_id", &mut data);    let url = take_str("url", &mut data);    let title = take_str("title", &mut data);    let referrer = take_str("referrer", &mut data);    let user_agent = ua_string.clone();    data.remove("user_agent");    let platform = take_str("platform", &mut data);    let browser = take_str("browser", &mut data);    let device = take_str("device", &mut data);    let screen_width = take_i64("screen_width", &mut data);    let screen_height = take_i64("screen_height", &mut data);    let country = take_str("country", &mut data);    let region = take_str("region", &mut data);    let city = take_str("city", &mut data);    let (lat, lon) = match data.remove("loc") {        Some(Value::Array(arr)) if arr.len() >= 2 => (arr[0].as_f64(), arr[1].as_f64()),        _ => (None, None),    };    let utm_source = take_str("utm_source", &mut data);    let utm_medium = take_str("utm_medium", &mut data);    let utm_campaign = take_str("utm_campaign", &mut data);    let utm_term = take_str("utm_term", &mut data);    let utm_content = take_str("utm_content", &mut data);    let time_on_page_ms = take_i64("time_on_page", &mut data);    let _ = take_f64; // suppress unused    let extra = if data.is_empty() {        "{}".to_string()    } else {        Value::Object(data).to_string()    };    let _ = sqlx::query(        "INSERT INTO events (\            property_id, event, created_at, user_id, url, title, referrer, user_agent, \            platform, browser, device, screen_width, screen_height, country, region, city, \            lat, lon, utm_source, utm_medium, utm_campaign, utm_term, utm_content, \            time_on_page_ms, extra\        ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",    )    .bind(property_id.as_bytes().to_vec())    .bind(event_name)    .bind(now)    .bind(user_id.as_deref())    .bind(url.as_deref())    .bind(title.as_deref())    .bind(referrer.as_deref())    .bind(user_agent.as_deref())    .bind(platform.as_deref())    .bind(browser.as_deref())    .bind(device.as_deref())    .bind(screen_width)    .bind(screen_height)    .bind(country.as_deref())    .bind(region.as_deref())    .bind(city.as_deref())    .bind(lat)    .bind(lon)    .bind(utm_source.as_deref())    .bind(utm_medium.as_deref())    .bind(utm_campaign.as_deref())    .bind(utm_term.as_deref())    .bind(utm_content.as_deref())    .bind(time_on_page_ms)    .bind(extra)    .execute(&state.pool)    .await;    cors_204(&headers)}fn cors_204(req_headers: &HeaderMap) -> Response {    let mut h = HeaderMap::new();    let origin = req_headers        .get("origin")        .and_then(|v| v.to_str().ok())        .unwrap_or("*")        .to_string();    h.insert("access-control-allow-origin", origin.parse().unwrap());    (StatusCode::NO_CONTENT, h).into_response()}fn client_ip(headers: &HeaderMap) -> Option<IpAddr> {    if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {        if let Some(first) = xff.split(',').next() {            if let Ok(ip) = first.trim().parse::<IpAddr>() {                return Some(ip);            }        }    }    if let Some(real) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {        if let Ok(ip) = real.parse::<IpAddr>() {            return Some(ip);        }    }    None}
added src/db.rs
@@ -0,0 +1,69 @@use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};use sqlx::SqlitePool;use std::path::Path;use std::str::FromStr;use std::time::Duration;use uuid::Uuid;pub async fn init(data_dir: &Path) -> anyhow::Result<SqlitePool> {    let db_path = data_dir.join("db.sqlite3");    let url = format!("sqlite://{}", db_path.display());    // Ensure file exists so sqlx can attach.    if !db_path.exists() {        std::fs::File::create(&db_path)?;    }    let opts = SqliteConnectOptions::from_str(&url)?        .create_if_missing(true)        .journal_mode(SqliteJournalMode::Wal)        .synchronous(SqliteSynchronous::Normal)        .busy_timeout(Duration::from_secs(5))        .foreign_keys(true);    let pool = SqlitePoolOptions::new()        .max_connections(8)        .connect_with(opts)        .await?;    sqlx::migrate!("./migrations").run(&pool).await?;    Ok(pool)}pub async fn ensure_proprium(pool: &SqlitePool) -> anyhow::Result<Uuid> {    let existing: Option<(String,)> =        sqlx::query_as("SELECT value FROM meta WHERE key = 'proprium_id'")            .fetch_optional(pool)            .await?;    if let Some((s,)) = existing {        if let Ok(uuid) = Uuid::parse_str(&s) {            // Make sure the property still exists (db could have been wiped).            let row: Option<(Vec<u8>,)> =                sqlx::query_as("SELECT id FROM properties WHERE id = ?")                    .bind(uuid.as_bytes().to_vec())                    .fetch_optional(pool)                    .await?;            if row.is_some() {                return Ok(uuid);            }        }    }    let id = Uuid::new_v4();    let now = chrono::Utc::now().timestamp_millis();    sqlx::query(        r#"INSERT INTO properties (id, name, custom_cards, is_protected, is_public, created_at, updated_at)           VALUES (?, 'Proprium', '[]', 1, 0, ?, ?)"#,    )    .bind(id.as_bytes().to_vec())    .bind(now)    .bind(now)    .execute(pool)    .await?;    sqlx::query("INSERT OR REPLACE INTO meta (key, value) VALUES ('proprium_id', ?)")        .bind(id.to_string())        .execute(pool)        .await?;    Ok(id)}
added src/geoip.rs
@@ -0,0 +1,149 @@use chrono::Datelike;use maxminddb::geoip2;use std::net::IpAddr;use std::path::{Path, PathBuf};use std::sync::RwLock;pub struct GeoIp {    path: PathBuf,    reader: RwLock<Option<maxminddb::Reader<Vec<u8>>>>,}#[derive(Debug, Clone, Default)]pub struct GeoLookup {    pub country: Option<String>,    pub region: Option<String>,    pub city: Option<String>,    pub lat: Option<f64>,    pub lon: Option<f64>,}impl GeoIp {    pub fn load(path: &Path) -> Self {        let reader = maxminddb::Reader::open_readfile(path).ok();        if reader.is_some() {            tracing::info!("geoip db loaded from {}", path.display());        } else {            tracing::warn!(                "geoip db missing at {} — country/region enrichment disabled until refresh",                path.display()            );        }        Self {            path: path.to_path_buf(),            reader: RwLock::new(reader),        }    }    pub fn reload(&self) -> bool {        let new_reader = maxminddb::Reader::open_readfile(&self.path).ok();        let ok = new_reader.is_some();        if let Ok(mut w) = self.reader.write() {            *w = new_reader;        }        ok    }    pub fn lookup(&self, ip: IpAddr) -> Option<GeoLookup> {        let guard = self.reader.read().ok()?;        let reader = guard.as_ref()?;        let city: geoip2::City = reader.lookup(ip).ok()?;        let country = city            .country            .as_ref()            .and_then(|c| c.iso_code.as_ref().map(|s| s.to_string()));        let region = city            .subdivisions            .as_ref()            .and_then(|subs| subs.first())            .and_then(|s| {                s.names                    .as_ref()                    .and_then(|n| n.get("en").map(|v| v.to_string()))                    .or_else(|| s.iso_code.map(|s| s.to_string()))            });        let city_name = city            .city            .as_ref()            .and_then(|c| c.names.as_ref())            .and_then(|n| n.get("en").map(|v| v.to_string()));        let (lat, lon) = city            .location            .as_ref()            .map(|l| (l.latitude, l.longitude))            .unwrap_or((None, None));        Some(GeoLookup { country, region, city: city_name, lat, lon })    }}/// Download the latest DB-IP City Lite mmdb to `dest` if missing or older than 30 days./// CC-BY-4.0, no signup required.////// DB-IP rolls each month's file on the 1st, but with a few hours of lag./// Try this month, last month, then two months back so a first-of-the-month/// boot doesn't 404 us into a degraded state.pub async fn ensure_db(dest: &Path) -> anyhow::Result<bool> {    if dest.exists() {        if let Ok(meta) = std::fs::metadata(dest) {            if let Ok(modified) = meta.modified() {                let age = std::time::SystemTime::now()                    .duration_since(modified)                    .unwrap_or_default();                if age.as_secs() < 30 * 24 * 60 * 60 {                    return Ok(false);                }            }        }    }    let today = chrono::Utc::now().date_naive();    let mut last_err: Option<anyhow::Error> = None;    for offset in 0i64..3 {        let target = month_offset(today, offset);        let url = format!(            "https://download.db-ip.com/free/dbip-city-lite-{}-{:02}.mmdb.gz",            target.year(),            target.month()        );        match download_gz_to(&url, dest).await {            Ok(()) => {                tracing::info!("downloaded geoip db from {url}");                return Ok(true);            }            Err(e) => {                tracing::warn!(                    "geoip download failed for {}-{:02}: {e}",                    target.year(),                    target.month()                );                last_err = Some(e);            }        }    }    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("geoip download failed (no candidates)")))}/// Return the first-of-the-month for `today` shifted back `offset` months.fn month_offset(today: chrono::NaiveDate, offset: i64) -> chrono::NaiveDate {    let mut y = today.year();    let mut m = today.month() as i64 - offset;    while m <= 0 {        m += 12;        y -= 1;    }    chrono::NaiveDate::from_ymd_opt(y, m as u32, 1).unwrap_or(today)}async fn download_gz_to(url: &str, dest: &Path) -> anyhow::Result<()> {    let bytes = reqwest::get(url).await?.error_for_status()?.bytes().await?;    use std::io::Read;    let mut decoder = flate2::read::GzDecoder::new(&bytes[..]);    let mut out = Vec::new();    decoder.read_to_end(&mut out)?;    if let Some(parent) = dest.parent() {        std::fs::create_dir_all(parent)?;    }    std::fs::write(dest, out)?;    Ok(())}
added src/main.rs
@@ -0,0 +1,290 @@mod auth;mod cache;mod collector;mod db;mod geoip;mod migrate;mod models;mod pages;mod pdf;mod queries;mod templates;mod ua;mod views;use axum::{    extract::Request,    http::{header, HeaderValue, StatusCode},    middleware::{self, Next},    response::{IntoResponse, Response},    routing::{get, post},    Router,};use chrono::Local;use minijinja::Environment;use sqlx::SqlitePool;use std::net::SocketAddr;use std::path::PathBuf;use std::sync::Arc;use std::time::Instant;use tower_cookies::{CookieManagerLayer, Key};use tower_http::cors::{Any, CorsLayer};use tower_http::services::ServeDir;use tower_http::set_header::SetResponseHeaderLayer;use crate::cache::DashboardCache;use crate::geoip::GeoIp;use crate::ua::UaParser;#[derive(Clone)]pub struct AppState {    pub env: Arc<Environment<'static>>,    pub pool: SqlitePool,    pub cookie_key: Key,    pub geoip: Arc<GeoIp>,    pub ua: Arc<UaParser>,    pub cache: DashboardCache,    pub config: Arc<Config>,}#[derive(Debug, Clone)]pub struct Config {    pub root: PathBuf,    pub data_dir: PathBuf,    pub password: String,    pub base_url: String,    pub proprium_id: Option<uuid::Uuid>,}#[tokio::main]async fn main() -> anyhow::Result<()> {    dotenvy::dotenv().ok();    tracing_subscriber::fmt()        .with_env_filter(            tracing_subscriber::EnvFilter::try_from_default_env()                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,sqlx=warn")),        )        .init();    // Subcommand dispatch. Anything besides `migrate` falls through to the server.    let mut argv = std::env::args().skip(1);    if let Some(first) = argv.next() {        match first.as_str() {            "migrate" => return run_migrate(argv.collect()).await,            "--help" | "-h" => {                print_usage();                return Ok(());            }            other if !other.is_empty() => {                eprintln!("unknown subcommand: {other}");                print_usage();                std::process::exit(2);            }            _ => {}        }    }    let root: PathBuf = std::env::var("ANALYTICS_ROOT")        .map(PathBuf::from)        .unwrap_or_else(|_| PathBuf::from("."));    let data_dir = std::env::var("ANALYTICS_DATA_DIR")        .map(PathBuf::from)        .unwrap_or_else(|_| root.join("data"));    std::fs::create_dir_all(&data_dir)?;    let templates_dir = root.join("templates");    let dist_dir = root.join("dist");    let static_maps_dir = root.join("static_maps");    let manifest_path = dist_dir.join(".vite/manifest.json");    let port: u16 = std::env::var("PORT")        .ok()        .and_then(|v| v.parse().ok())        .unwrap_or(8000);    let password = std::env::var("ANALYTICS_PASSWORD").unwrap_or_else(|_| "admin".to_string());    let base_url = std::env::var("BASE_URL").unwrap_or_default();    let cookie_secret = std::env::var("ANALYTICS_COOKIE_SECRET").unwrap_or_else(|_| {        // 64+ bytes derived from password if no secret provided. For a single-user        // self-hosted app this is fine; setting ANALYTICS_COOKIE_SECRET is preferred.        use sha2::{Digest, Sha512};        let mut h = Sha512::new();        h.update(b"analytics-cookie:");        h.update(password.as_bytes());        let digest = h.finalize();        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, digest)    });    let cookie_key = Key::from(cookie_secret.as_bytes());    let pool = db::init(&data_dir).await?;    let proprium_id = db::ensure_proprium(&pool).await?;    tracing::info!("Proprium property: {}", proprium_id);    let geoip_path = data_dir.join("db.mmdb");    let regexes_path = data_dir.join("regexes.yaml");    let geoip = Arc::new(GeoIp::load(&geoip_path));    let ua = Arc::new(UaParser::load(&regexes_path));    // Best-effort background downloads. Server boots immediately; once these    // finish the next collector hit picks up the loaded data.    {        let geoip = geoip.clone();        let geoip_path = geoip_path.clone();        tokio::spawn(async move {            match geoip::ensure_db(&geoip_path).await {                Ok(true) => {                    geoip.reload();                }                Ok(false) => {}                Err(e) => tracing::warn!("geoip download skipped: {e}"),            }        });    }    {        let regexes_path = regexes_path.clone();        tokio::spawn(async move {            if let Err(e) = ua::ensure_regexes(&regexes_path).await {                tracing::warn!("uaparser regexes download skipped: {e}");            }            // Note: hot-reload of UA parser would need RwLock too. For now            // the download primes the file for the next process restart.        });    }    let env = templates::build_env(&templates_dir, &manifest_path);    let config = Arc::new(Config {        root: root.clone(),        data_dir: data_dir.clone(),        password,        base_url,        proprium_id: Some(proprium_id),    });    let cache = DashboardCache::new();    let state = AppState {        env: Arc::new(env),        pool,        cookie_key,        geoip,        ua,        cache,        config: config.clone(),    };    let collector_cors = CorsLayer::new()        .allow_origin(Any)        .allow_methods(Any)        .allow_headers(Any);    let static_cache = SetResponseHeaderLayer::if_not_present(        header::CACHE_CONTROL,        HeaderValue::from_static("public, max-age=31536000"),    );    let app = Router::new()        .route("/", get(pages::home))        .route("/login", get(auth::login_form).post(auth::login_submit))        .route("/logout", post(auth::logout))        .route("/properties", get(views::properties).post(views::properties_create))        .route("/properties/{id}/delete", post(views::property_delete))        .route("/properties/{id}/cards", post(views::property_cards))        .route("/properties/{id}/public", post(views::property_public_toggle))        .route("/changelog", get(pages::changelog))        .route("/documentation", get(pages::documentation))        .route("/favicon.ico", get(pages::favicon))        .route("/robots.txt", get(pages::robots))        .route("/sitemap.xml", get(pages::sitemap))        .route("/collect", post(collector::collect).options(collector::options))        .route("/collect/", post(collector::collect).options(collector::options))        .layer(collector_cors)        // Property dashboard uses a UUID path segment. Keep it last so the named        // routes above take precedence.        .route("/{property_id}", get(views::property))        .nest_service(            "/static",            tower::ServiceBuilder::new()                .layer(static_cache.clone())                .service(ServeDir::new(&dist_dir)),        )        .nest_service(            "/static_maps",            tower::ServiceBuilder::new()                .layer(static_cache)                .service(ServeDir::new(&static_maps_dir)),        )        .fallback(not_found)        .layer(CookieManagerLayer::new())        .layer(middleware::from_fn(log_requests))        .with_state(state);    let addr = SocketAddr::from(([0, 0, 0, 0], port));    let listener = tokio::net::TcpListener::bind(addr).await?;    tracing::info!("analytics listening on http://{addr}");    axum::serve(listener, app).await?;    Ok(())}async fn log_requests(req: Request, next: Next) -> Response {    let method = req.method().clone();    let path = req        .uri()        .path_and_query()        .map(|p| p.as_str().to_string())        .unwrap_or_else(|| req.uri().path().to_string());    let start = Instant::now();    let response = next.run(req).await;    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;    let status = response.status().as_u16();    let now = Local::now().format("%H:%M:%S");    let color = match status {        200..=299 => "\x1b[32m",        300..=399 => "\x1b[36m",        400..=499 => "\x1b[33m",        _ => "\x1b[31m",    };    eprintln!("{now} {method:<5} {color}{status}\x1b[0m {elapsed_ms:>7.2}ms  {path}");    response}async fn not_found() -> Response {    (StatusCode::NOT_FOUND, "404 Not Found").into_response()}fn print_usage() {    eprintln!(        "analytics — single-binary axum analytics server\n\         \n\         Usage:\n  \           analytics                              run the HTTP server\n  \           analytics migrate <path> [--force]     import a Django analytics SQLite\n"    );}async fn run_migrate(args: Vec<String>) -> anyhow::Result<()> {    let mut source: Option<PathBuf> = None;    let mut force = false;    for arg in args {        match arg.as_str() {            "--force" | "-f" => force = true,            "--help" | "-h" => {                eprintln!("Usage: analytics migrate <path-to-django-sqlite3> [--force]");                return Ok(());            }            v if v.starts_with("--") => anyhow::bail!("unknown migrate flag: {v}"),            v => {                if source.is_some() {                    anyhow::bail!("migrate takes a single source path");                }                source = Some(PathBuf::from(v));            }        }    }    let source = source.ok_or_else(|| {        anyhow::anyhow!("usage: analytics migrate <path-to-django-sqlite3> [--force]")    })?;    migrate::run(source, force).await}
added src/migrate.rs
@@ -0,0 +1,234 @@//! One-shot migration from the original Django analytics SQLite into the//! Rust hot-field schema. Preserves property UUIDs so embedded snippets on//! tracked sites keep working without a snippet rotation.//!//! Invoked as a subcommand of the main binary so it ships in the existing//! Docker image with no extra wiring://!//! ```text//! ./analytics migrate <path-to-django.sqlite3> [--force]//! ```//!//! Without `--force`, refuses to run if the destination has any properties,//! events, or bot_events. With `--force`, wipes those tables (and `meta`)//! before importing — so an auto-created Proprium row from a prior boot is//! replaced with the original Proprium from Django.use anyhow::{bail, Context, Result};use sqlx::{Acquire, Row};use std::path::{Path, PathBuf};use uuid::Uuid;pub async fn run(source: PathBuf, force: bool) -> Result<()> {    if !source.exists() {        bail!("source database not found: {}", source.display());    }    let data_dir = std::env::var("ANALYTICS_DATA_DIR")        .map(PathBuf::from)        .unwrap_or_else(|_| PathBuf::from("./data"));    std::fs::create_dir_all(&data_dir)?;    let pool = crate::db::init(&data_dir).await?;    let dest_props: i64 =        sqlx::query_scalar("SELECT COUNT(*) FROM properties").fetch_one(&pool).await?;    let dest_events: i64 =        sqlx::query_scalar("SELECT COUNT(*) FROM events").fetch_one(&pool).await?;    let dest_bots: i64 =        sqlx::query_scalar("SELECT COUNT(*) FROM bot_events").fetch_one(&pool).await?;    if dest_props + dest_events + dest_bots > 0 {        if !force {            bail!(                "destination not empty (properties={dest_props}, events={dest_events}, bot_events={dest_bots}); pass --force to wipe before migrating"            );        }        eprintln!(            "wiping destination: {dest_props} properties, {dest_events} events, {dest_bots} bot_events"        );        sqlx::query("DELETE FROM events").execute(&pool).await?;        sqlx::query("DELETE FROM bot_events").execute(&pool).await?;        sqlx::query("DELETE FROM properties").execute(&pool).await?;        sqlx::query("DELETE FROM meta").execute(&pool).await?;    }    // ATTACH source DB. SQLite won't attach across pool connections cleanly,    // so grab a single connection and use it for the whole migration.    let mut conn = pool.acquire().await?;    let attach_sql = format!("ATTACH DATABASE '{}' AS src", escape_path(&source));    sqlx::query(&attach_sql).execute(&mut *conn).await?;    // 1. Read django properties (host-side parse so we can convert hex UUIDs    //    to 16-byte BLOBs in Rust without depending on a specific SQLite    //    version's unhex() availability).    let prop_rows = sqlx::query(        "SELECT id, name, custom_cards, is_protected, is_public, created_at, updated_at \         FROM src.properties_property",    )    .fetch_all(&mut *conn)    .await    .context("reading source properties")?;    if prop_rows.is_empty() {        bail!("source database has no properties; nothing to migrate");    }    eprintln!("found {} properties in source", prop_rows.len());    let mut tx = conn.begin().await?;    let mut proprium_blob: Option<Vec<u8>> = None;    for row in &prop_rows {        let id_text: String = row.try_get("id")?;        let name: String = row.try_get("name")?;        let custom_cards: Option<String> = row.try_get("custom_cards")?;        let is_protected: i64 = row.try_get("is_protected")?;        let is_public: i64 = row.try_get("is_public")?;        let created_at: String = row.try_get("created_at")?;        let updated_at: String = row.try_get("updated_at")?;        let uuid = parse_django_uuid(&id_text)            .with_context(|| format!("parsing property id {id_text:?}"))?;        let id_blob = uuid.as_bytes().to_vec();        sqlx::query(            "INSERT INTO properties (id, name, custom_cards, is_protected, is_public, created_at, updated_at) \             VALUES (?, ?, ?, ?, ?, \                     CAST((julianday(?) - 2440587.5) * 86400000 AS INTEGER), \                     CAST((julianday(?) - 2440587.5) * 86400000 AS INTEGER))",        )        .bind(&id_blob)        .bind(&name)        .bind(custom_cards.unwrap_or_else(|| "[]".to_string()))        .bind(is_protected)        .bind(is_public)        .bind(&created_at)        .bind(&updated_at)        .execute(&mut *tx)        .await        .with_context(|| format!("inserting property {name:?}"))?;        if name == "Proprium" && is_protected != 0 {            proprium_blob = Some(id_blob);        }    }    // 2. Build a temp mapping (text-hex id → BLOB) so the events INSERT…SELECT    //    can join across the ATTACHed database without per-row Rust roundtrips.    sqlx::query("CREATE TEMP TABLE prop_id_map (text_id TEXT PRIMARY KEY, blob_id BLOB NOT NULL)")        .execute(&mut *tx)        .await?;    for row in &prop_rows {        let id_text: String = row.try_get("id")?;        let uuid = parse_django_uuid(&id_text)?;        sqlx::query("INSERT INTO prop_id_map (text_id, blob_id) VALUES (?, ?)")            .bind(&id_text)            .bind(uuid.as_bytes().to_vec())            .execute(&mut *tx)            .await?;    }    // 3. Bot events first — rows where data.is_bot is set route to bot_events    //    with a smaller projection.    let bot_count = sqlx::query(        "INSERT INTO bot_events (property_id, event, created_at, bot_name, url, user_agent, country, extra) \         SELECT \             m.blob_id, \             e.event, \             CAST((julianday(e.created_at) - 2440587.5) * 86400000 AS INTEGER), \             json_extract(e.data, '$.bot_name'), \             json_extract(e.data, '$.url'), \             json_extract(e.data, '$.user_agent'), \             json_extract(e.data, '$.country'), \             '{}' \         FROM src.properties_event e \         JOIN prop_id_map m ON e.property_id = m.text_id \         WHERE json_extract(e.data, '$.is_bot') IS NOT NULL",    )    .execute(&mut *tx)    .await    .context("inserting bot_events")?    .rows_affected();    // 4. Human events. Project every hot field via json_extract; lat/lon    //    come out of Django's `loc: [lat, lon]` array; `time_on_page` (ms)    //    flows into `time_on_page_ms`. The `extra` blob stays empty since    //    Django stored everything in `data` and the hot fields cover what    //    the dashboard uses.    let human_count = sqlx::query(        "INSERT INTO events ( \             property_id, event, created_at, user_id, url, title, referrer, user_agent, \             platform, browser, device, screen_width, screen_height, country, region, city, \             lat, lon, utm_source, utm_medium, utm_campaign, utm_term, utm_content, \             time_on_page_ms, extra \         ) \         SELECT \             m.blob_id, \             e.event, \             CAST((julianday(e.created_at) - 2440587.5) * 86400000 AS INTEGER), \             CAST(json_extract(e.data, '$.user_id') AS TEXT), \             json_extract(e.data, '$.url'), \             json_extract(e.data, '$.title'), \             json_extract(e.data, '$.referrer'), \             json_extract(e.data, '$.user_agent'), \             json_extract(e.data, '$.platform'), \             json_extract(e.data, '$.browser'), \             json_extract(e.data, '$.device'), \             json_extract(e.data, '$.screen_width'), \             json_extract(e.data, '$.screen_height'), \             json_extract(e.data, '$.country'), \             json_extract(e.data, '$.region'), \             json_extract(e.data, '$.city'), \             json_extract(e.data, '$.loc[0]'), \             json_extract(e.data, '$.loc[1]'), \             json_extract(e.data, '$.utm_source'), \             json_extract(e.data, '$.utm_medium'), \             json_extract(e.data, '$.utm_campaign'), \             json_extract(e.data, '$.utm_term'), \             json_extract(e.data, '$.utm_content'), \             json_extract(e.data, '$.time_on_page'), \             '{}' \         FROM src.properties_event e \         JOIN prop_id_map m ON e.property_id = m.text_id \         WHERE json_extract(e.data, '$.is_bot') IS NULL",    )    .execute(&mut *tx)    .await    .context("inserting events")?    .rows_affected();    // 5. Persist the Proprium id so self-tracking continues without a fresh row.    if let Some(blob) = proprium_blob {        let uuid = Uuid::from_slice(&blob)?;        sqlx::query("INSERT OR REPLACE INTO meta (key, value) VALUES ('proprium_id', ?)")            .bind(uuid.to_string())            .execute(&mut *tx)            .await?;        eprintln!("set proprium_id = {uuid}");    } else {        eprintln!("no Proprium property found in source — server will create a new one on next boot");    }    sqlx::query("DROP TABLE prop_id_map").execute(&mut *tx).await?;    tx.commit().await?;    sqlx::query("DETACH DATABASE src").execute(&mut *conn).await?;    eprintln!(        "migrated {} properties, {human_count} events, {bot_count} bot_events",        prop_rows.len()    );    Ok(())}/// Parse a Django-stored UUID (32 lowercase hex chars, no dashes) into a `Uuid`.fn parse_django_uuid(s: &str) -> Result<Uuid> {    Uuid::parse_str(s).context("expected 32-hex Django UUID")}/// Quote a path for use in an `ATTACH DATABASE 'path' AS src` statement./// SQLite uses single-quote-doubled escaping inside string literals.fn escape_path(p: &Path) -> String {    p.display().to_string().replace('\'', "''")}
added src/models.rs
@@ -0,0 +1,49 @@use serde::{Deserialize, Serialize};use sqlx::FromRow;use uuid::Uuid;#[derive(Debug, Clone, Serialize)]pub struct Property {    pub id: Uuid,    pub name: String,    pub custom_cards: Vec<CustomCard>,    pub is_protected: bool,    pub is_public: bool,    pub created_at: i64,    pub updated_at: i64,}#[derive(Debug, Clone, Serialize, Deserialize)]pub struct CustomCard {    pub event: String,    #[serde(default)]    pub value: bool,}#[derive(Debug, FromRow)]pub struct PropertyRow {    pub id: Vec<u8>,    pub name: String,    pub custom_cards: String,    pub is_protected: i64,    pub is_public: i64,    pub created_at: i64,    pub updated_at: i64,}impl PropertyRow {    pub fn into_property(self) -> Property {        let id = Uuid::from_slice(&self.id).unwrap_or_default();        let custom_cards: Vec<CustomCard> =            serde_json::from_str(&self.custom_cards).unwrap_or_default();        Property {            id,            name: self.name,            custom_cards,            is_protected: self.is_protected != 0,            is_public: self.is_public != 0,            created_at: self.created_at,            updated_at: self.updated_at,        }    }}
added src/pages.rs
@@ -0,0 +1,156 @@use axum::{    extract::State,    http::{header, HeaderMap, StatusCode},    response::{Html, IntoResponse, Response},};use chrono::Datelike;use tower_cookies::Cookies;use crate::auth::is_authenticated;use crate::AppState;fn render_page(    state: &AppState,    template: &str,    title: &str,    description: &str,    authed: bool,    path: &str,    extra: minijinja::Value,) -> Response {    let tmpl = match state.env.get_template(template) {        Ok(t) => t,        Err(e) => {            tracing::error!("template '{}': {}", template, e);            return (StatusCode::INTERNAL_SERVER_ERROR, "template error").into_response();        }    };    let request = crate::templates::RequestCtx {        url: String::new(),        url_root: "/".to_string(),        base_url: String::new(),        path: path.to_string(),    };    let body = tmpl.render(minijinja::context! {        page => minijinja::context! { title => title, description => description },        user => crate::templates::UserCtx { is_authenticated: authed },        request => &request,        now => minijinja::context! { year => chrono::Local::now().year() },        base_url => &state.config.base_url,        collector_id => state.config.proprium_id.map(|u| u.to_string()),        collector_server => &state.config.base_url,        messages => Vec::<()>::new(),        ..extra    });    match body {        Ok(b) => Html(b).into_response(),        Err(e) => {            tracing::error!("render '{}': {}", template, e);            (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response()        }    }}pub async fn home(State(state): State<AppState>, cookies: Cookies) -> Response {    let authed = is_authenticated(&cookies, &state);    if authed {        return axum::response::Redirect::to("/properties").into_response();    }    let totals: (i64, i64, Option<i64>) = sqlx::query_as(        "SELECT \           (SELECT COUNT(*) FROM properties), \           (SELECT COUNT(*) FROM events), \           (SELECT MIN(created_at) FROM events)",    )    .fetch_one(&state.pool)    .await    .unwrap_or((0, 0, None));    let first = totals.2.and_then(|ms| {        chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ms)            .map(|d| d.format("%b %-d, %Y").to_string())    });    let extra = minijinja::context! {        total_properties => totals.0,        total_events => totals.1,        total_users => 1,        first_event_created_at => first,    };    render_page(        &state,        "pages/home.html",        "Self-hosted analytics",        "Self-hosted website analytics. Page views, clicks, scrolls, sessions, and custom events.",        authed,        "/",        extra,    )}pub async fn changelog(State(state): State<AppState>, cookies: Cookies) -> Response {    let authed = is_authenticated(&cookies, &state);    render_page(        &state,        "pages/changelog.html",        "Changelog",        "What's new in Analytics.",        authed,        "/changelog",        minijinja::context! {},    )}pub async fn documentation(State(state): State<AppState>, cookies: Cookies) -> Response {    let authed = is_authenticated(&cookies, &state);    render_page(        &state,        "pages/documentation.html",        "Documentation",        "How to embed and operate Analytics.",        authed,        "/documentation",        minijinja::context! {},    )}pub async fn favicon() -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());    let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">  <rect x="6"  y="38" width="10" height="22" rx="1.5" fill="#6b9e78"/>  <rect x="20" y="28" width="10" height="32" rx="1.5" fill="#6b9e78"/>  <rect x="34" y="18" width="10" height="42" rx="1.5" fill="#6b9e78"/>  <rect x="48" y="8"  width="10" height="52" rx="1.5" fill="#6b9e78"/>  <rect x="48" y="8"  width="10" height="6"  rx="1.5" fill="#c9a84c"/></svg>"##;    (StatusCode::OK, h, svg).into_response()}pub async fn robots() -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "text/plain".parse().unwrap());    (StatusCode::OK, h, "User-agent: *\nAllow: /\n").into_response()}pub async fn sitemap(State(state): State<AppState>) -> Response {    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "application/xml".parse().unwrap());    let base = if state.config.base_url.is_empty() {        "/".to_string()    } else {        format!("{}/", state.config.base_url.trim_end_matches('/'))    };    let now = chrono::Utc::now().format("%Y-%m-%d");    let body = format!(        r##"<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">  <url><loc>{base}</loc><lastmod>{now}</lastmod></url>  <url><loc>{base}documentation</loc><lastmod>{now}</lastmod></url>  <url><loc>{base}changelog</loc><lastmod>{now}</lastmod></url></urlset>"##    );    let _ = chrono::Local::now().year(); // keep chrono::Datelike used    (StatusCode::OK, h, body).into_response()}
added src/pdf.rs
@@ -0,0 +1,106 @@use std::path::PathBuf;use std::process::Command;use tempfile::{Builder, NamedTempFile};/// Render an HTML string to a PDF using headless Chromium.////// Spawns chrome-headless-shell with --print-to-pdf. The HTML is written to a/// tempfile and loaded as a file:// URL so relative URLs (e.g. /content/images/...)/// can be rewritten to absolute http:// URLs pointing at the live server.pub fn html_to_pdf(html: &str, server_base: &str) -> anyhow::Result<Vec<u8>> {    // Rewrite relative absolute paths (/content/, /static/) to point at the    // running server so chromium can fetch them.    let rewritten = html        .replace("src=\"/content/", &format!("src=\"{server_base}/content/"))        .replace("href=\"/content/", &format!("href=\"{server_base}/content/"))        .replace("src=\"/static/", &format!("src=\"{server_base}/static/"))        .replace("href=\"/static/", &format!("href=\"{server_base}/static/"));    // Suffix matters: chromium decides HTML vs plain-text rendering by extension.    // Without .html the page is shown as raw source text instead of being rendered.    let mut html_file = Builder::new().suffix(".html").tempfile()?;    {        use std::io::Write;        html_file.write_all(rewritten.as_bytes())?;        html_file.flush()?;    }    let html_path = html_file.path().to_path_buf();    let pdf_file = NamedTempFile::new()?;    let pdf_path = pdf_file.path().to_path_buf();    let url = format!("file://{}", html_path.display());    let print_arg = format!("--print-to-pdf={}", pdf_path.display());    run_chromium(&url, &print_arg)?;    let bytes = std::fs::read(&pdf_path)?;    Ok(bytes)}fn run_chromium(url: &str, print_arg: &str) -> anyhow::Result<()> {    let bin = find_chromium().ok_or_else(|| {        anyhow::anyhow!("could not locate chromium; set CHROMIUM_BIN or install chromium on PATH")    })?;    let output = Command::new(&bin)        .arg("--headless=new")        .arg("--no-sandbox")        .arg("--disable-gpu")        .arg("--no-pdf-header-footer")        .arg("--hide-scrollbars")        .arg(print_arg)        .arg(url)        .output()?;    if !output.status.success() {        anyhow::bail!(            "chromium ({}) exited {}: {}",            bin.display(),            output.status,            String::from_utf8_lossy(&output.stderr)        );    }    Ok(())}/// Locate a chromium binary across environments:/// 1. `CHROMIUM_BIN` env var (explicit override)/// 2. PATH search for common chromium binary names (production install)/// 3. Glob under `/opt/playwright-browsers/` (dev container with Playwright)fn find_chromium() -> Option<PathBuf> {    if let Ok(p) = std::env::var("CHROMIUM_BIN") {        let path = PathBuf::from(p);        if path.is_file() {            return Some(path);        }    }    let names = [        "chromium",        "chromium-browser",        "chrome-headless-shell",        "google-chrome",        "chrome",    ];    if let Some(path_var) = std::env::var_os("PATH") {        for dir in std::env::split_paths(&path_var) {            for name in &names {                let candidate = dir.join(name);                if candidate.is_file() {                    return Some(candidate);                }            }        }    }    if let Ok(entries) = std::fs::read_dir("/opt/playwright-browsers") {        for entry in entries.flatten() {            let candidate = entry                .path()                .join("chrome-headless-shell-linux64/chrome-headless-shell");            if candidate.is_file() {                return Some(candidate);            }        }    }    None}
added src/queries.rs
@@ -0,0 +1,717 @@//! Dashboard aggregation queries. Mirror of `properties/queries.py` from the//! Django version, but talking to the hot-field schema so most aggregations//! become straight COUNT(*) over typed columns.//!//! Time arithmetic uses unix milliseconds (matches `events.created_at`).use chrono::{DateTime, Duration, NaiveDate, TimeZone, Utc};use serde::Serialize;use sqlx::SqlitePool;use uuid::Uuid;use crate::models::CustomCard;const BUILT_IN_EVENTS: &[&str] = &["session_start", "page_view", "page_leave", "click", "scroll"];const TIME_ON_PAGE_MIN_S: f64 = 1.0;const TIME_ON_PAGE_MAX_S: f64 = 30.0 * 60.0;#[derive(Debug, Clone, Serialize)]pub struct LabelCount {    pub label: String,    pub count: i64,}#[derive(Debug, Clone, Serialize)]pub struct GraphPoint {    pub label: String,    pub count: i64,}#[derive(Debug, Clone, Serialize)]pub struct EventCard {    pub name: String,    pub value: serde_json::Value,    pub percent_change: i64,    #[serde(skip_serializing_if = "Option::is_none")]    pub help_text: Option<String>,}#[derive(Debug, Clone, Serialize)]pub struct CustomEventDescriptor {    pub event: String,    pub active: bool,}#[derive(Debug, Clone, Serialize, Default)]pub struct BotTraffic {    pub total: i64,    pub top_bots: Vec<LabelCount>,    pub top_pages: Vec<LabelCount>,}#[derive(Debug, Clone, Default)]pub struct EventCounts {    pub session_start: i64,    pub page_view: i64,    pub click: i64,    pub scroll: i64,    pub total: i64,}fn pct_change(current: f64, previous: f64) -> i64 {    if previous == 0.0 {        return 0;    }    ((current - previous) / previous * 100.0).round() as i64}fn filter_clause(filter_url: Option<&str>) -> (&'static str, Option<String>) {    match filter_url {        Some(_) => (" AND url = ?", filter_url.map(|s| s.to_string())),        None => ("", None),    }}/// Total unique user_ids seen in the last 30 minutes.pub async fn total_live_users(pool: &SqlitePool, property_id: &Uuid) -> i64 {    let cutoff = (Utc::now() - Duration::minutes(30)).timestamp_millis();    sqlx::query_scalar::<_, i64>(        "SELECT COUNT(DISTINCT user_id) FROM events \         WHERE property_id = ? AND created_at >= ? AND user_id IS NOT NULL",    )    .bind(property_id.as_bytes().to_vec())    .bind(cutoff)    .fetch_one(pool)    .await    .unwrap_or(0)}pub async fn event_counts(    pool: &SqlitePool,    property_id: &Uuid,    start_ms: i64,    end_ms: i64,    filter_url: Option<&str>,) -> EventCounts {    let (extra_sql, extra_bind) = filter_clause(filter_url);    let sql = format!(        "SELECT \            SUM(CASE WHEN event = 'session_start' THEN 1 ELSE 0 END) AS session_start, \            SUM(CASE WHEN event = 'page_view'     THEN 1 ELSE 0 END) AS page_view, \            SUM(CASE WHEN event = 'click'         THEN 1 ELSE 0 END) AS click, \            SUM(CASE WHEN event = 'scroll'        THEN 1 ELSE 0 END) AS scroll, \            COUNT(*) AS total \         FROM events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ?{}",        extra_sql    );    let mut q = sqlx::query_as::<_, (Option<i64>, Option<i64>, Option<i64>, Option<i64>, i64)>(&sql)        .bind(property_id.as_bytes().to_vec())        .bind(start_ms)        .bind(end_ms);    if let Some(v) = extra_bind {        q = q.bind(v);    }    let row = q.fetch_one(pool).await.unwrap_or((None, None, None, None, 0));    EventCounts {        session_start: row.0.unwrap_or(0),        page_view: row.1.unwrap_or(0),        click: row.2.unwrap_or(0),        scroll: row.3.unwrap_or(0),        total: row.4,    }}async fn engaged_users(    pool: &SqlitePool,    property_id: &Uuid,    start_ms: i64,    end_ms: i64,    filter_url: Option<&str>,    session_starts: i64,) -> f64 {    if session_starts == 0 {        return 0.0;    }    let (extra_sql, extra_bind) = filter_clause(filter_url);    let sql = format!(        "SELECT COUNT(*) FROM ( \           SELECT user_id FROM events \           WHERE property_id = ? AND created_at >= ? AND created_at <= ? \                 AND user_id IS NOT NULL{} \           GROUP BY user_id HAVING COUNT(*) >= 10 \         )",        extra_sql    );    let mut q = sqlx::query_scalar::<_, i64>(&sql)        .bind(property_id.as_bytes().to_vec())        .bind(start_ms)        .bind(end_ms);    if let Some(v) = extra_bind {        q = q.bind(v);    }    let engaged = q.fetch_one(pool).await.unwrap_or(0);    ((engaged as f64) / (session_starts as f64) * 100.0 * 100.0).round() / 100.0}async fn avg_time_on_page(    pool: &SqlitePool,    property_id: &Uuid,    start_ms: i64,    end_ms: i64,    filter_url: Option<&str>,) -> f64 {    let (extra_sql, extra_bind) = filter_clause(filter_url);    let sql = format!(        "SELECT AVG(time_on_page_ms / 1000.0) FROM events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ? \               AND event = 'page_leave' \               AND time_on_page_ms IS NOT NULL \               AND time_on_page_ms / 1000.0 BETWEEN ? AND ?{}",        extra_sql    );    let mut q = sqlx::query_scalar::<_, Option<f64>>(&sql)        .bind(property_id.as_bytes().to_vec())        .bind(start_ms)        .bind(end_ms)        .bind(TIME_ON_PAGE_MIN_S)        .bind(TIME_ON_PAGE_MAX_S);    if let Some(v) = extra_bind {        q = q.bind(v);    }    let avg = q.fetch_one(pool).await.unwrap_or(None).unwrap_or(0.0);    (avg * 100.0).round() / 100.0}pub async fn standard_event_cards(    pool: &SqlitePool,    property_id: &Uuid,    start_ms: i64,    end_ms: i64,    prev_start_ms: i64,    prev_end_ms: i64,    filter_url: Option<&str>,) -> Vec<EventCard> {    let cur = event_counts(pool, property_id, start_ms, end_ms, filter_url).await;    let prev = event_counts(pool, property_id, prev_start_ms, prev_end_ms, filter_url).await;    let mut cards = vec![        EventCard {            name: "Total session starts".into(),            value: cur.session_start.into(),            percent_change: pct_change(cur.session_start as f64, prev.session_start as f64),            help_text: Some("Unique users visiting your site for your selected date range.".into()),        },        EventCard {            name: "Total page views".into(),            value: cur.page_view.into(),            percent_change: pct_change(cur.page_view as f64, prev.page_view as f64),            help_text: Some("Total pages viewed for your selected date range.".into()),        },        EventCard {            name: "Total clicks".into(),            value: cur.click.into(),            percent_change: pct_change(cur.click as f64, prev.click as f64),            help_text: Some("Total clicks users made on all your pages for your selected date range.".into()),        },        EventCard {            name: "Total scrolls".into(),            value: cur.scroll.into(),            percent_change: pct_change(cur.scroll as f64, prev.scroll as f64),            help_text: Some("Total scrolls users made on all your pages for your selected date range.".into()),        },        EventCard {            name: "Total events".into(),            value: cur.total.into(),            percent_change: pct_change(cur.total as f64, prev.total as f64),            help_text: Some("All events for your selected date range, including custom events.".into()),        },    ];    let eng_cur =        engaged_users(pool, property_id, start_ms, end_ms, filter_url, cur.session_start).await;    let eng_prev = engaged_users(        pool,        property_id,        prev_start_ms,        prev_end_ms,        filter_url,        prev.session_start,    )    .await;    cards.push(EventCard {        name: "Total user engagement".into(),        value: format!("{eng_cur}%").into(),        percent_change: pct_change(eng_cur, eng_prev),        help_text: Some("An engaged user is a user with more than 10 events collected for your selected date range.".into()),    });    let t_cur = avg_time_on_page(pool, property_id, start_ms, end_ms, filter_url).await;    let t_prev = avg_time_on_page(pool, property_id, prev_start_ms, prev_end_ms, filter_url).await;    cards.push(EventCard {        name: "Avg. time on page".into(),        value: format!("{t_cur}s").into(),        percent_change: pct_change(t_cur, t_prev),        help_text: Some("Average time a user spends on each page. Sessions over 30 minutes are excluded as idle.".into()),    });    cards}pub async fn custom_event_cards(    pool: &SqlitePool,    property_id: &Uuid,    custom_cards: &[CustomCard],    start_ms: i64,    end_ms: i64,    prev_start_ms: i64,    prev_end_ms: i64,    filter_url: Option<&str>,) -> (Vec<EventCard>, Vec<CustomEventDescriptor>) {    // All non-built-in event names that have ever been seen for this property.    let placeholders = std::iter::repeat("?")        .take(BUILT_IN_EVENTS.len())        .collect::<Vec<_>>()        .join(",");    let sql = format!(        "SELECT DISTINCT event FROM events \         WHERE property_id = ? AND event NOT IN ({}) \         ORDER BY event",        placeholders    );    let mut q = sqlx::query_scalar::<_, String>(&sql).bind(property_id.as_bytes().to_vec());    for built in BUILT_IN_EVENTS {        q = q.bind(built);    }    let names: Vec<String> = q.fetch_all(pool).await.unwrap_or_default();    let active: std::collections::HashSet<&str> = custom_cards        .iter()        .filter(|c| c.value)        .map(|c| c.event.as_str())        .collect();    let descriptors: Vec<CustomEventDescriptor> = names        .iter()        .map(|n| CustomEventDescriptor {            event: n.clone(),            active: active.contains(n.as_str()),        })        .collect();    if active.is_empty() {        return (Vec::new(), descriptors);    }    // Aggregate counts for active custom events in current and previous periods.    let active_names: Vec<&str> = active.iter().copied().collect();    let count_for = |period_start: i64, period_end: i64| {        let placeholders = std::iter::repeat("?")            .take(active_names.len())            .collect::<Vec<_>>()            .join(",");        let (extra_sql, extra_bind) = filter_clause(filter_url);        let sql = format!(            "SELECT event, COUNT(*) FROM events \             WHERE property_id = ? AND created_at >= ? AND created_at <= ? \                   AND event IN ({}){} \             GROUP BY event",            placeholders, extra_sql        );        let pool = pool.clone();        let property_id = *property_id;        let names: Vec<String> = active_names.iter().map(|s| (*s).to_string()).collect();        let extra_bind = extra_bind.clone();        async move {            let mut q = sqlx::query_as::<_, (String, i64)>(&sql)                .bind(property_id.as_bytes().to_vec())                .bind(period_start)                .bind(period_end);            for n in &names {                q = q.bind(n);            }            if let Some(v) = extra_bind {                q = q.bind(v);            }            q.fetch_all(&pool).await.unwrap_or_default()        }    };    let cur_rows = count_for(start_ms, end_ms).await;    let prev_rows = count_for(prev_start_ms, prev_end_ms).await;    let cur_map: std::collections::HashMap<String, i64> = cur_rows.into_iter().collect();    let prev_map: std::collections::HashMap<String, i64> = prev_rows.into_iter().collect();    let cards: Vec<EventCard> = active_names        .iter()        .map(|name| {            let v = *cur_map.get(*name).unwrap_or(&0);            let p = *prev_map.get(*name).unwrap_or(&0);            EventCard {                name: (*name).to_string(),                value: v.into(),                percent_change: pct_change(v as f64, p as f64),                help_text: None,            }        })        .collect();    (cards, descriptors)}/// Time-series chart data. Buckets daily/weekly/monthly based on the date range,/// stepping backwards from `end_date`.pub async fn events_graph(    pool: &SqlitePool,    property_id: &Uuid,    start_ms: i64,    end_ms: i64,    filter_url: Option<&str>,    end_date: NaiveDate,    range_days: i64,) -> Vec<GraphPoint> {    let (extra_sql, extra_bind) = filter_clause(filter_url);    let sql = format!(        "SELECT date(created_at / 1000, 'unixepoch') AS day, COUNT(*) \         FROM events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ?{} \         GROUP BY day",        extra_sql    );    let mut q = sqlx::query_as::<_, (String, i64)>(&sql)        .bind(property_id.as_bytes().to_vec())        .bind(start_ms)        .bind(end_ms);    if let Some(v) = extra_bind {        q = q.bind(v);    }    let rows = q.fetch_all(pool).await.unwrap_or_default();    let mut by_day: std::collections::HashMap<NaiveDate, i64> =        std::collections::HashMap::with_capacity(rows.len());    for (s, c) in rows {        if let Ok(d) = NaiveDate::parse_from_str(&s, "%Y-%m-%d") {            by_day.insert(d, c);        }    }    let bucket_sum = |start: NaiveDate, days: i64| -> i64 {        (0..days)            .map(|j| {                start                    .checked_add_signed(Duration::days(j))                    .and_then(|d| by_day.get(&d).copied())                    .unwrap_or(0)            })            .sum()    };    let mut points = Vec::new();    if range_days <= 28 {        for i in 0..range_days {            if let Some(d) = end_date.checked_sub_signed(Duration::days(i)) {                points.push((d, by_day.get(&d).copied().unwrap_or(0)));            }        }    } else if range_days <= 6 * 28 {        let weeks = range_days / 7;        for w in 0..weeks {            if let Some(d) = end_date.checked_sub_signed(Duration::days(7 * w)) {                points.push((d, bucket_sum(d, 7)));            }        }    } else {        let months = range_days / 28;        for m in 0..months {            if let Some(d) = end_date.checked_sub_signed(Duration::days(28 * m)) {                points.push((d, bucket_sum(d, 28)));            }        }    }    points.sort_by_key(|p| p.0);    points        .into_iter()        .map(|(d, c)| GraphPoint { label: d.format("%b %-d").to_string(), count: c })        .collect()}async fn top_by_column(    pool: &SqlitePool,    property_id: &Uuid,    start_ms: i64,    end_ms: i64,    filter_url: Option<&str>,    column: &str,    event: Option<&str>,    limit: i64,) -> Vec<LabelCount> {    let mut sql = format!(        "SELECT {col}, COUNT(*) FROM events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ? \               AND {col} IS NOT NULL AND {col} != ''",        col = column    );    if event.is_some() {        sql.push_str(" AND event = ?");    }    let (extra_sql, extra_bind) = filter_clause(filter_url);    sql.push_str(extra_sql);    sql.push_str(&format!(" GROUP BY {col} ORDER BY COUNT(*) DESC LIMIT ?", col = column));    let mut q = sqlx::query_as::<_, (String, i64)>(&sql)        .bind(property_id.as_bytes().to_vec())        .bind(start_ms)        .bind(end_ms);    if let Some(e) = event {        q = q.bind(e);    }    if let Some(v) = extra_bind {        q = q.bind(v);    }    q = q.bind(limit);    let rows = q.fetch_all(pool).await.unwrap_or_default();    rows.into_iter()        .map(|(label, count)| LabelCount { label, count })        .collect()}pub async fn events_by_screen_size(    pool: &SqlitePool,    property_id: &Uuid,    start_ms: i64,    end_ms: i64,    filter_url: Option<&str>,    limit: i64,) -> Vec<LabelCount> {    let mut sql = String::from(        "SELECT screen_width, screen_height, COUNT(*) FROM events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ? \               AND event = 'session_start' \               AND screen_width IS NOT NULL",    );    let (extra_sql, extra_bind) = filter_clause(filter_url);    sql.push_str(extra_sql);    sql.push_str(" GROUP BY screen_width, screen_height ORDER BY COUNT(*) DESC LIMIT ?");    let mut q = sqlx::query_as::<_, (Option<i64>, Option<i64>, i64)>(&sql)        .bind(property_id.as_bytes().to_vec())        .bind(start_ms)        .bind(end_ms);    if let Some(v) = extra_bind {        q = q.bind(v);    }    q = q.bind(limit);    q.fetch_all(pool)        .await        .unwrap_or_default()        .into_iter()        .map(|(w, h, c)| LabelCount {            label: format!("{}x{}", w.unwrap_or(0), h.unwrap_or(0)),            count: c,        })        .collect()}pub async fn events_by_device(pool: &SqlitePool, property_id: &Uuid, start_ms: i64, end_ms: i64, filter_url: Option<&str>, limit: i64) -> Vec<LabelCount> {    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "device", Some("session_start"), limit).await}pub async fn events_by_browser(pool: &SqlitePool, property_id: &Uuid, start_ms: i64, end_ms: i64, filter_url: Option<&str>, limit: i64) -> Vec<LabelCount> {    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "browser", Some("session_start"), limit).await}pub async fn events_by_platform(pool: &SqlitePool, property_id: &Uuid, start_ms: i64, end_ms: i64, filter_url: Option<&str>, limit: i64) -> Vec<LabelCount> {    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "platform", Some("session_start"), limit).await}pub async fn events_by_page_url(pool: &SqlitePool, property_id: &Uuid, start_ms: i64, end_ms: i64, filter_url: Option<&str>, limit: i64) -> Vec<LabelCount> {    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "url", None, limit).await}pub async fn page_views_by_page_url(pool: &SqlitePool, property_id: &Uuid, start_ms: i64, end_ms: i64, filter_url: Option<&str>, limit: i64) -> Vec<LabelCount> {    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "url", Some("page_view"), limit).await}pub async fn session_starts_by_referrer(pool: &SqlitePool, property_id: &Uuid, start_ms: i64, end_ms: i64, filter_url: Option<&str>, limit: i64) -> Vec<LabelCount> {    top_by_column(pool, property_id, start_ms, end_ms, filter_url, "referrer", Some("session_start"), limit).await}pub async fn page_views_by_utm(    pool: &SqlitePool,    property_id: &Uuid,    start_ms: i64,    end_ms: i64,    filter_url: Option<&str>,    field: &str,    limit: i64,) -> Vec<LabelCount> {    let column = match field {        "source" => "utm_source",        "medium" => "utm_medium",        "campaign" => "utm_campaign",        "term" => "utm_term",        "content" => "utm_content",        _ => return Vec::new(),    };    top_by_column(pool, property_id, start_ms, end_ms, filter_url, column, Some("page_view"), limit).await}pub async fn events_by_custom_event(    pool: &SqlitePool,    property_id: &Uuid,    start_ms: i64,    end_ms: i64,    filter_url: Option<&str>,    limit: i64,) -> Vec<LabelCount> {    let placeholders = std::iter::repeat("?")        .take(BUILT_IN_EVENTS.len())        .collect::<Vec<_>>()        .join(",");    let (extra_sql, extra_bind) = filter_clause(filter_url);    let sql = format!(        "SELECT event, COUNT(*) FROM events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ? \               AND event NOT IN ({}){} \         GROUP BY event ORDER BY COUNT(*) DESC LIMIT ?",        placeholders, extra_sql    );    let mut q = sqlx::query_as::<_, (String, i64)>(&sql)        .bind(property_id.as_bytes().to_vec())        .bind(start_ms)        .bind(end_ms);    for built in BUILT_IN_EVENTS {        q = q.bind(built);    }    if let Some(v) = extra_bind {        q = q.bind(v);    }    q = q.bind(limit);    q.fetch_all(pool)        .await        .unwrap_or_default()        .into_iter()        .map(|(label, count)| LabelCount { label, count })        .collect()}pub async fn session_starts_by_country(    pool: &SqlitePool,    property_id: &Uuid,    start_ms: i64,    end_ms: i64,    filter_url: Option<&str>,) -> std::collections::HashMap<String, i64> {    let (extra_sql, extra_bind) = filter_clause(filter_url);    let sql = format!(        "SELECT country, COUNT(*) FROM events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ? \               AND event = 'session_start' AND country IS NOT NULL{} \         GROUP BY country",        extra_sql    );    let mut q = sqlx::query_as::<_, (String, i64)>(&sql)        .bind(property_id.as_bytes().to_vec())        .bind(start_ms)        .bind(end_ms);    if let Some(v) = extra_bind {        q = q.bind(v);    }    q.fetch_all(pool).await.unwrap_or_default().into_iter().collect()}pub async fn session_starts_by_country_region(    pool: &SqlitePool,    property_id: &Uuid,    start_ms: i64,    end_ms: i64,    filter_url: Option<&str>,) -> std::collections::HashMap<String, std::collections::HashMap<String, i64>> {    let (extra_sql, extra_bind) = filter_clause(filter_url);    let sql = format!(        "SELECT country, region, COUNT(*) FROM events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ? \               AND event = 'session_start' \               AND country IS NOT NULL AND region IS NOT NULL{} \         GROUP BY country, region",        extra_sql    );    let mut q = sqlx::query_as::<_, (String, String, i64)>(&sql)        .bind(property_id.as_bytes().to_vec())        .bind(start_ms)        .bind(end_ms);    if let Some(v) = extra_bind {        q = q.bind(v);    }    let rows = q.fetch_all(pool).await.unwrap_or_default();    let mut out: std::collections::HashMap<String, std::collections::HashMap<String, i64>> =        std::collections::HashMap::new();    for (country, region, count) in rows {        out.entry(country).or_default().insert(region, count);    }    out}pub async fn bot_traffic(    pool: &SqlitePool,    property_id: &Uuid,    start_ms: i64,    end_ms: i64,    limit: i64,) -> BotTraffic {    let total: i64 = sqlx::query_scalar(        "SELECT COUNT(*) FROM bot_events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ?",    )    .bind(property_id.as_bytes().to_vec())    .bind(start_ms)    .bind(end_ms)    .fetch_one(pool)    .await    .unwrap_or(0);    if total == 0 {        return BotTraffic::default();    }    let top_bots = sqlx::query_as::<_, (String, i64)>(        "SELECT bot_name, COUNT(*) FROM bot_events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ? \               AND bot_name IS NOT NULL AND bot_name != '' \         GROUP BY bot_name ORDER BY COUNT(*) DESC LIMIT ?",    )    .bind(property_id.as_bytes().to_vec())    .bind(start_ms)    .bind(end_ms)    .bind(limit)    .fetch_all(pool)    .await    .unwrap_or_default()    .into_iter()    .map(|(label, count)| LabelCount { label, count })    .collect();    let top_pages = sqlx::query_as::<_, (String, i64)>(        "SELECT url, COUNT(*) FROM bot_events \         WHERE property_id = ? AND created_at >= ? AND created_at <= ? \               AND url IS NOT NULL AND url != '' \         GROUP BY url ORDER BY COUNT(*) DESC LIMIT ?",    )    .bind(property_id.as_bytes().to_vec())    .bind(start_ms)    .bind(end_ms)    .bind(limit)    .fetch_all(pool)    .await    .unwrap_or_default()    .into_iter()    .map(|(label, count)| LabelCount { label, count })    .collect();    BotTraffic { total, top_bots, top_pages }}/// Convert "YYYY-MM-DD" + a time-of-day to a unix-ms timestamp in the local tz.pub fn parse_date_to_ms(date: &str, end_of_day: bool) -> Option<i64> {    let nd = NaiveDate::parse_from_str(date, "%Y-%m-%d").ok()?;    let nt = if end_of_day {        chrono::NaiveTime::from_hms_opt(23, 59, 59)?    } else {        chrono::NaiveTime::from_hms_opt(0, 0, 0)?    };    let local: DateTime<chrono::Local> = chrono::Local        .from_local_datetime(&chrono::NaiveDateTime::new(nd, nt))        .single()?;    Some(local.with_timezone(&Utc).timestamp_millis())}
added src/templates.rs
@@ -0,0 +1,216 @@use minijinja::value::{Kwargs, Value};use minijinja::{path_loader, AutoEscape, Environment, Error, ErrorKind, Output, State};use serde::Serialize;use serde_json::Value as JsonValue;use std::path::Path;/// Jinja2-faithful HTML formatter — does NOT escape `/`, so vite asset URLs/// like `/static/base-abc123.js` come through clean instead of `&#x2f;...`.fn jinja2_html_formatter(out: &mut Output, state: &State, value: &Value) -> Result<(), Error> {    if value.is_safe() {        write!(out, "{value}").map_err(Error::from)?;        return Ok(());    }    let auto_escape = match state.auto_escape() {        AutoEscape::Html => true,        AutoEscape::None => false,        _ => return minijinja::escape_formatter(out, state, value),    };    if !auto_escape {        write!(out, "{value}").map_err(Error::from)?;        return Ok(());    }    if let Some(s) = value.as_str() {        write_jinja2_html(out, s).map_err(Error::from)?;    } else if value.is_undefined() || value.is_none() {        // emit nothing    } else {        let stringified = value.to_string();        write_jinja2_html(out, &stringified).map_err(Error::from)?;    }    Ok(())}fn write_jinja2_html(out: &mut Output, s: &str) -> std::fmt::Result {    let mut last = 0;    for (i, b) in s.bytes().enumerate() {        let escape = match b {            b'&' => "&amp;",            b'<' => "&lt;",            b'>' => "&gt;",            b'"' => "&#34;",            b'\'' => "&#39;",            _ => continue,        };        if last < i {            out.write_str(&s[last..i])?;        }        out.write_str(escape)?;        last = i + 1;    }    if last < s.len() {        out.write_str(&s[last..])?;    }    Ok(())}#[derive(Debug, Clone, Serialize)]pub struct RequestCtx {    pub url: String,    pub url_root: String,    pub base_url: String,    pub path: String,}#[derive(Debug, Clone, Serialize, Default)]pub struct UserCtx {    pub is_authenticated: bool,}fn read_manifest(path: &Path) -> JsonValue {    let text = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());    serde_json::from_str(&text).unwrap_or(JsonValue::Null)}fn lookup_asset(manifest: &JsonValue, entry: &str, kind: &str) -> String {    if let Some(chunk) = manifest.get(entry) {        if kind == "css" {            if let Some(css_arr) = chunk.get("css").and_then(|v| v.as_array()) {                if let Some(first) = css_arr.first().and_then(|v| v.as_str()) {                    return format!("/static/{first}");                }            }        }        if let Some(file) = chunk.get("file").and_then(|v| v.as_str()) {            return format!("/static/{file}");        }    }    format!("/static/{entry}")}pub fn build_env(templates_dir: &Path, manifest_path: &Path) -> Environment<'static> {    let mut env = Environment::new();    env.set_loader(path_loader(templates_dir));    env.set_formatter(jinja2_html_formatter);    #[cfg(debug_assertions)]    {        let path = manifest_path.to_path_buf();        env.add_function(            "vite_asset",            move |entry: String, kind: Option<String>| -> Result<String, Error> {                let kind = kind.unwrap_or_else(|| "file".to_string());                let manifest = read_manifest(&path);                Ok(lookup_asset(&manifest, &entry, &kind))            },        );    }    #[cfg(not(debug_assertions))]    {        let manifest = read_manifest(manifest_path);        env.add_function(            "vite_asset",            move |entry: String, kind: Option<String>| -> Result<String, Error> {                let kind = kind.unwrap_or_else(|| "file".to_string());                Ok(lookup_asset(&manifest, &entry, &kind))            },        );    }    env.add_function("url_for", url_for);    env.add_filter("naturaltime", naturaltime_filter);    env.add_filter("urlencode", urlencode_filter);    env}fn urlencode_filter(value: Value) -> Result<String, Error> {    let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());    Ok(urlencoding::encode(&s).into_owned())}/// Subset of Django's url_for/url tags. We only need to emit a URL string,/// so we only support the names referenced by templates.fn url_for(_state: &State, endpoint: String, kwargs: Kwargs) -> Result<String, Error> {    let take_str = |k: &str| -> Result<Option<String>, Error> {        let v: Option<Value> = kwargs.get(k).ok();        match v {            None => Ok(None),            Some(val) => {                if val.is_undefined() || val.is_none() {                    Ok(None)                } else {                    Ok(Some(val.to_string()))                }            }        }    };    let path = match endpoint.as_str() {        "home" | "index" => "/".to_string(),        "login" => "/login".to_string(),        "logout" => "/logout".to_string(),        "properties" => "/properties".to_string(),        "property" => {            let id = take_str("property_id")?.unwrap_or_default();            format!("/{id}")        }        "property_delete" => {            let id = take_str("property_id")?.unwrap_or_default();            format!("/properties/{id}/delete")        }        "property_cards" => {            let id = take_str("property_id")?.unwrap_or_default();            format!("/properties/{id}/cards")        }        "property_public" => {            let id = take_str("property_id")?.unwrap_or_default();            format!("/properties/{id}/public")        }        "documentation" => "/documentation".to_string(),        "changelog" => "/changelog".to_string(),        "favicon" => "/favicon.ico".to_string(),        "static" => {            let filename = take_str("filename")?.unwrap_or_default();            format!("/static/{filename}")        }        other => {            return Err(Error::new(                ErrorKind::InvalidOperation,                format!("unknown route in url_for: {other}"),            ));        }    };    kwargs.assert_all_used()?;    Ok(path)}/// Mimics Django's humanize "naturaltime" for createdAt timestamps.fn naturaltime_filter(value: Value) -> Result<String, Error> {    let s = value.as_str().map(|s| s.to_string()).unwrap_or_else(|| value.to_string());    let dt = chrono::DateTime::parse_from_rfc3339(&s)        .map(|d| d.with_timezone(&chrono::Utc))        .ok();    let Some(dt) = dt else { return Ok(s) };    let now = chrono::Utc::now();    let diff = now.signed_duration_since(dt);    let secs = diff.num_seconds();    Ok(if secs < 60 {        "just now".to_string()    } else if secs < 3600 {        let m = secs / 60;        format!("{m} minute{} ago", if m == 1 { "" } else { "s" })    } else if secs < 86_400 {        let h = secs / 3600;        format!("{h} hour{} ago", if h == 1 { "" } else { "s" })    } else if secs < 86_400 * 30 {        let d = secs / 86_400;        format!("{d} day{} ago", if d == 1 { "" } else { "s" })    } else if secs < 86_400 * 365 {        let m = secs / (86_400 * 30);        format!("{m} month{} ago", if m == 1 { "" } else { "s" })    } else {        let y = secs / (86_400 * 365);        format!("{y} year{} ago", if y == 1 { "" } else { "s" })    })}
added src/ua.rs
@@ -0,0 +1,134 @@use std::path::Path;use uaparser::{Parser, UserAgentParser};pub struct UaParser {    parser: Option<UserAgentParser>,}#[derive(Debug, Clone, Default)]pub struct ParsedUa {    pub platform: Option<String>,    pub browser: Option<String>,    pub device: Option<String>, // Mobile | Tablet | Desktop    pub is_bot: bool,    pub bot_name: Option<String>,}impl UaParser {    /// Loads regexes.yaml from `data_dir/regexes.yaml` if present, else falls    /// back to a substring heuristic. Use `ensure_regexes` to download.    pub fn load(path: &std::path::Path) -> Self {        if path.exists() {            if let Some(parser) = try_load(path) {                tracing::info!("uaparser regexes loaded from {}", path.display());                return Self { parser: Some(parser) };            }        }        tracing::warn!(            "uaparser regexes.yaml not found — ua parsing falls back to a substring heuristic until refresh"        );        Self { parser: None }    }    pub fn reload(&mut self, path: &std::path::Path) {        if path.exists() {            if let Some(parser) = try_load(path) {                self.parser = Some(parser);            }        }    }    pub fn parse(&self, ua: &str) -> ParsedUa {        if let Some(parser) = &self.parser {            let client = parser.parse(ua);            let platform = match client.os.family.as_ref() {                "Other" => None,                other => Some(other.to_string()),            };            let browser = match client.user_agent.family.as_ref() {                "Other" => None,                other => Some(other.to_string()),            };            let device_family = client.device.family.as_ref();            let is_bot = matches!(                device_family,                "Spider" | "Spider Desktop" | "Spider Smartphone" | "Spider Tablet"            ) || classify_bot_by_ua(ua);            let device = if is_bot {                None            } else {                Some(classify_device(ua, device_family).to_string())            };            let bot_name = if is_bot {                Some(client.user_agent.family.to_string()).filter(|s| s != "Other")            } else {                None            };            return ParsedUa { platform, browser, device, is_bot, bot_name };        }        // Fallback heuristic so the collector still works without regexes.yaml.        let is_bot = classify_bot_by_ua(ua);        let device = if is_bot { None } else { Some(classify_device(ua, "").to_string()) };        ParsedUa {            platform: None,            browser: None,            device,            is_bot,            bot_name: if is_bot { Some("Unknown bot".to_string()) } else { None },        }    }}fn try_load(path: &Path) -> Option<UserAgentParser> {    UserAgentParser::builder()        .with_unicode_support(false)        .build_from_yaml(path.to_string_lossy().as_ref())        .ok()}/// Download the canonical ua-parser regexes.yaml on first boot if missing./// Source: https://github.com/ua-parser/uap-core (Apache-2.0).pub async fn ensure_regexes(dest: &Path) -> anyhow::Result<bool> {    if dest.exists() {        return Ok(false);    }    let url = "https://raw.githubusercontent.com/ua-parser/uap-core/master/regexes.yaml";    let bytes = reqwest::get(url).await?.error_for_status()?.bytes().await?;    if let Some(parent) = dest.parent() {        std::fs::create_dir_all(parent)?;    }    std::fs::write(dest, &bytes)?;    tracing::info!("downloaded ua-parser regexes to {}", dest.display());    Ok(true)}fn classify_bot_by_ua(ua: &str) -> bool {    let ua = ua.to_ascii_lowercase();    const NEEDLES: &[&str] = &[        "bot", "crawl", "spider", "slurp", "facebookexternalhit", "ahrefs", "semrush",        "petalbot", "yandex", "bingpreview", "duckduckgo", "discordbot", "whatsapp",        "telegrambot", "applebot", "linkedinbot", "embedly", "headlesschrome",        "phantomjs", "lighthouse", "pingdom", "uptimerobot", "monitor",    ];    NEEDLES.iter().any(|n| ua.contains(n))}fn classify_device(ua: &str, family: &str) -> &'static str {    let lower = ua.to_ascii_lowercase();    let tablet = matches!(family, "iPad" | "Tablet")        || lower.contains("tablet")        || lower.contains("ipad");    if tablet {        return "Tablet";    }    let mobile = matches!(family, "iPhone" | "iPod" | "Generic Smartphone")        || lower.contains("mobile")        || lower.contains("iphone")        || lower.contains("android");    if mobile {        "Mobile"    } else {        "Desktop"    }}
added src/views.rs
@@ -0,0 +1,665 @@use axum::{    extract::{Form, Path as AxumPath, Query, State},    http::StatusCode,    response::{Html, IntoResponse, Json, Redirect, Response},};use chrono::Datelike;use serde::Deserialize;use serde_json::json;use tower_cookies::Cookies;use uuid::Uuid;use crate::auth::is_authenticated;use crate::templates::{RequestCtx, UserCtx};use crate::AppState;fn now_year() -> i32 {    use chrono::Datelike;    chrono::Local::now().year()}fn render(    state: &AppState,    template: &str,    extra: minijinja::Value,    authed: bool,    path: &str,) -> Result<Html<String>, StatusCode> {    let tmpl = state        .env        .get_template(template)        .map_err(|e| {            tracing::error!("template '{}': {}", template, e);            StatusCode::INTERNAL_SERVER_ERROR        })?;    let request = RequestCtx {        url: String::new(),        url_root: "/".to_string(),        base_url: String::new(),        path: path.to_string(),    };    let body = tmpl        .render(minijinja::context! {            user => UserCtx { is_authenticated: authed },            request => &request,            now => minijinja::context! { year => now_year() },            base_url => &state.config.base_url,            collector_id => state.config.proprium_id.map(|u| u.to_string()),            collector_server => &state.config.base_url,            messages => Vec::<()>::new(),            ..extra        })        .map_err(|e| {            tracing::error!("render '{}': {}", template, e);            StatusCode::INTERNAL_SERVER_ERROR        })?;    Ok(Html(body))}#[derive(Debug, Deserialize)]pub struct PropertiesQuery {    #[serde(default)]    pub q: Option<String>,}#[derive(Debug, Deserialize)]pub struct PropertyCreateForm {    pub name: String,}pub async fn properties(    State(state): State<AppState>,    cookies: Cookies,    Query(q): Query<PropertiesQuery>,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    let search = q.q.as_deref().unwrap_or("").trim().to_string();    let rows = if search.is_empty() {        sqlx::query_as::<_, crate::models::PropertyRow>(            "SELECT id, name, custom_cards, is_protected, is_public, created_at, updated_at \             FROM properties ORDER BY is_protected DESC, created_at ASC",        )        .fetch_all(&state.pool)        .await    } else {        let like = format!("%{}%", search);        sqlx::query_as::<_, crate::models::PropertyRow>(            "SELECT id, name, custom_cards, is_protected, is_public, created_at, updated_at \             FROM properties WHERE name LIKE ? ORDER BY is_protected DESC, created_at ASC",        )        .bind(like)        .fetch_all(&state.pool)        .await    };    let rows = match rows {        Ok(r) => r,        Err(e) => {            tracing::error!("properties query: {e}");            return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();        }    };    let mut props = Vec::with_capacity(rows.len());    let mut total_events = 0i64;    let mut total_page_views = 0i64;    let mut total_sessions = 0i64;    for row in rows {        let id_bytes = row.id.clone();        let p = row.into_property();        let counts: (i64, i64, i64, i64) = sqlx::query_as(            "SELECT \                (SELECT COUNT(*) FROM events WHERE property_id = ?1) AS total, \                (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND event = 'page_view') AS pv, \                (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND event = 'session_start') AS ss, \                (SELECT COUNT(*) FROM events WHERE property_id = ?1 AND created_at >= ?2) AS active",        )        .bind(&id_bytes)        .bind(chrono::Utc::now().timestamp_millis() - 7 * 24 * 60 * 60 * 1000)        .fetch_one(&state.pool)        .await        .unwrap_or((0, 0, 0, 0));        total_events += counts.0;        total_page_views += counts.1;        total_sessions += counts.2;        props.push(json!({            "id": p.id.to_string(),            "name": p.name,            "is_protected": p.is_protected,            "is_public": p.is_public,            "is_active": counts.3 > 0,            "total_events": counts.0,            "total_page_views": counts.1,            "total_session_starts": counts.2,        }));    }    let totals = json!({        "properties": props.len(),        "events": total_events,        "page_views": total_page_views,        "sessions": total_sessions,    });    let extra = minijinja::context! {        page => minijinja::context! {            title => "Properties",            description => "Manage your properties.",        },        properties => &props,        totals => &totals,        q => &search,    };    render(&state, "properties/properties.html", extra, true, "/properties")        .map(IntoResponse::into_response)        .unwrap_or_else(|e| e.into_response())}pub async fn properties_create(    State(state): State<AppState>,    cookies: Cookies,    Form(form): Form<PropertyCreateForm>,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    let name = form.name.trim();    if name.is_empty() {        return Redirect::to("/properties").into_response();    }    let id = Uuid::new_v4();    let now = chrono::Utc::now().timestamp_millis();    let res = sqlx::query(        "INSERT INTO properties (id, name, custom_cards, is_protected, is_public, created_at, updated_at) \         VALUES (?, ?, '[]', 0, 0, ?, ?)",    )    .bind(id.as_bytes().to_vec())    .bind(name)    .bind(now)    .bind(now)    .execute(&state.pool)    .await;    if let Err(e) = res {        tracing::error!("create property: {e}");        return (StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response();    }    Redirect::to("/properties").into_response()}#[derive(Debug, Deserialize)]pub struct DashboardQuery {    pub date_start: Option<String>,    pub date_end: Option<String>,    pub date_range: Option<String>,    pub filter_url: Option<String>,    pub report: Option<String>,}pub async fn property(    State(state): State<AppState>,    AxumPath(property_id): AxumPath<Uuid>,    cookies: Cookies,    Query(q): Query<DashboardQuery>,) -> Response {    let row: Option<crate::models::PropertyRow> = sqlx::query_as(        "SELECT id, name, custom_cards, is_protected, is_public, created_at, updated_at \         FROM properties WHERE id = ?",    )    .bind(property_id.as_bytes().to_vec())    .fetch_optional(&state.pool)    .await    .unwrap_or(None);    let Some(row) = row else {        return Redirect::to("/properties").into_response();    };    let p = row.into_property();    let authed = is_authenticated(&cookies, &state);    if !p.is_public && !authed {        return Redirect::to("/login").into_response();    }    use chrono::{Duration, Local};    let today = Local::now().date_naive();    let default_start = today - Duration::days(28);    let date_start = q.date_start.clone().unwrap_or_else(|| default_start.format("%Y-%m-%d").to_string());    let date_end = q.date_end.clone().unwrap_or_else(|| today.format("%Y-%m-%d").to_string());    let start_ms = match crate::queries::parse_date_to_ms(&date_start, false) {        Some(v) => v,        None => return (StatusCode::BAD_REQUEST, "bad date_start").into_response(),    };    let end_ms = match crate::queries::parse_date_to_ms(&date_end, true) {        Some(v) => v,        None => return (StatusCode::BAD_REQUEST, "bad date_end").into_response(),    };    let date_range: i64 = match q.date_range.as_deref() {        Some("custom") | None => {            // Days between start and end, inclusive of the end-of-day window.            let span = (end_ms - start_ms) / (24 * 60 * 60 * 1000);            span.max(1)        }        Some(other) => other.parse::<i64>().unwrap_or(28),    };    let prev_start_ms = start_ms - date_range * 24 * 60 * 60 * 1000;    let prev_end_ms = end_ms - date_range * 24 * 60 * 60 * 1000;    let filter_url = q.filter_url.as_deref().filter(|s| !s.is_empty());    // Cache key includes property updated_at so card/visibility edits bust it.    let cache_key = format!(        "dash:{}:{}:{}:{}:{}:{}",        p.id,        p.updated_at,        date_start,        date_end,        date_range,        filter_url.unwrap_or("")    );    let bypass_cache = q.report.is_some();    let cached = if bypass_cache { None } else { state.cache.get(&cache_key).await };    let dash_value: serde_json::Value = if let Some(arc) = cached {        (*arc).clone()    } else {        let pool = &state.pool;        let pid = &p.id;        let event_cards =            crate::queries::standard_event_cards(pool, pid, start_ms, end_ms, prev_start_ms, prev_end_ms, filter_url).await;        let (custom_cards, custom_events) = crate::queries::custom_event_cards(            pool, pid, &p.custom_cards, start_ms, end_ms, prev_start_ms, prev_end_ms, filter_url,        )        .await;        let mut all_cards = event_cards;        all_cards.extend(custom_cards);        let total_events_graph = crate::queries::events_graph(            pool, pid, start_ms, end_ms, filter_url, today, date_range,        )        .await;        let total_events_by_screen_size =            crate::queries::events_by_screen_size(pool, pid, start_ms, end_ms, filter_url, 7).await;        let total_events_by_device =            crate::queries::events_by_device(pool, pid, start_ms, end_ms, filter_url, 7).await;        let total_events_by_browser =            crate::queries::events_by_browser(pool, pid, start_ms, end_ms, filter_url, 7).await;        let total_events_by_platform =            crate::queries::events_by_platform(pool, pid, start_ms, end_ms, filter_url, 7).await;        let total_events_by_page_url =            crate::queries::events_by_page_url(pool, pid, start_ms, end_ms, filter_url, 10).await;        let total_page_views_by_page_url =            crate::queries::page_views_by_page_url(pool, pid, start_ms, end_ms, filter_url, 10).await;        let total_events_by_custom_event =            crate::queries::events_by_custom_event(pool, pid, start_ms, end_ms, filter_url, 10).await;        let total_session_starts_by_referrer =            crate::queries::session_starts_by_referrer(pool, pid, start_ms, end_ms, filter_url, 10).await;        let total_page_views_by_utm_medium =            crate::queries::page_views_by_utm(pool, pid, start_ms, end_ms, filter_url, "medium", 10).await;        let total_page_views_by_utm_source =            crate::queries::page_views_by_utm(pool, pid, start_ms, end_ms, filter_url, "source", 10).await;        let total_page_views_by_utm_campaign =            crate::queries::page_views_by_utm(pool, pid, start_ms, end_ms, filter_url, "campaign", 10).await;        let session_starts_by_country =            crate::queries::session_starts_by_country(pool, pid, start_ms, end_ms, filter_url).await;        let session_starts_by_country_region =            crate::queries::session_starts_by_country_region(pool, pid, start_ms, end_ms, filter_url).await;        let bot_traffic =            crate::queries::bot_traffic(pool, pid, start_ms, end_ms, 10).await;        let v = serde_json::json!({            "event_cards": all_cards,            "custom_events": custom_events,            "total_events_graph": total_events_graph,            "total_events_by_screen_size": total_events_by_screen_size,            "total_events_by_device": total_events_by_device,            "total_events_by_browser": total_events_by_browser,            "total_events_by_platform": total_events_by_platform,            "total_events_by_page_url": total_events_by_page_url,            "total_page_views_by_page_url": total_page_views_by_page_url,            "total_events_by_custom_event": total_events_by_custom_event,            "total_session_starts_by_referrer": total_session_starts_by_referrer,            "total_page_views_by_utm_medium": total_page_views_by_utm_medium,            "total_page_views_by_utm_source": total_page_views_by_utm_source,            "total_page_views_by_utm_campaign": total_page_views_by_utm_campaign,            "session_starts_by_country": session_starts_by_country,            "session_starts_by_country_region": session_starts_by_country_region,            "bot_traffic": bot_traffic,        });        if !bypass_cache {            state                .cache                .insert(cache_key.clone(), std::sync::Arc::new(v.clone()))                .await;        }        v    };    let total_live_users = crate::queries::total_live_users(&state.pool, &p.id).await;    // Build the small chart helpers + breakdown totals the print template needs.    let chart_polyline = build_chart_polyline(        dash_value            .get("total_events_graph")            .and_then(|v| v.as_array())            .map(|v| v.as_slice())            .unwrap_or(&[]),    );    let graph_arr = dash_value        .get("total_events_graph")        .and_then(|v| v.as_array())        .cloned()        .unwrap_or_default();    let chart_label_start = graph_arr        .first()        .and_then(|p| p.get("label"))        .and_then(|l| l.as_str())        .unwrap_or("")        .to_string();    let chart_label_end = graph_arr        .last()        .and_then(|p| p.get("label"))        .and_then(|l| l.as_str())        .unwrap_or("")        .to_string();    let (chart_peak_count, chart_peak_label) = graph_arr        .iter()        .max_by_key(|p| p.get("count").and_then(|c| c.as_i64()).unwrap_or(0))        .map(|p| {            (                p.get("count").and_then(|c| c.as_i64()).unwrap_or(0),                p.get("label").and_then(|l| l.as_str()).unwrap_or("").to_string(),            )        })        .unwrap_or((0, String::new()));    let breakdown_total = |key: &str| -> i64 {        dash_value            .get(key)            .and_then(|v| v.as_array())            .map(|arr| {                arr.iter()                    .filter_map(|item| item.get("count").and_then(|c| c.as_i64()))                    .sum::<i64>()                    .max(1)            })            .unwrap_or(1)    };    let breakdown_totals = serde_json::json!({        "device": breakdown_total("total_events_by_device"),        "browser": breakdown_total("total_events_by_browser"),        "platform": breakdown_total("total_events_by_platform"),        "screen_size": breakdown_total("total_events_by_screen_size"),    });    let mut top_countries: Vec<serde_json::Value> = dash_value        .get("session_starts_by_country")        .and_then(|v| v.as_object())        .map(|m| {            m.iter()                .map(|(k, v)| {                    serde_json::json!({                        "label": k,                        "count": v.as_i64().unwrap_or(0),                    })                })                .collect()        })        .unwrap_or_default();    top_countries.sort_by_key(|v| -v.get("count").and_then(|c| c.as_i64()).unwrap_or(0));    top_countries.truncate(10);    let generated_at = chrono::Local::now().format("%Y-%m-%d %H:%M").to_string();    let extra = minijinja::context! {        page => minijinja::context! {            title => &p.name,            description => format!("Analytics for {}", p.name),        },        property => minijinja::context! {            id => p.id.to_string(),            name => &p.name,            is_protected => p.is_protected,            is_public => p.is_public,        },        date_start => &date_start,        date_end => &date_end,        date_range => date_range,        filter_url => filter_url,        total_live_users => total_live_users,        event_cards => dash_value.get("event_cards").cloned().unwrap_or(serde_json::Value::Array(vec![])),        custom_events => dash_value.get("custom_events").cloned().unwrap_or(serde_json::Value::Array(vec![])),        total_events_graph => dash_value.get("total_events_graph").cloned().unwrap_or(serde_json::Value::Array(vec![])),        total_events_by_screen_size => dash_value.get("total_events_by_screen_size").cloned().unwrap_or(serde_json::Value::Array(vec![])),        total_events_by_device => dash_value.get("total_events_by_device").cloned().unwrap_or(serde_json::Value::Array(vec![])),        total_events_by_browser => dash_value.get("total_events_by_browser").cloned().unwrap_or(serde_json::Value::Array(vec![])),        total_events_by_platform => dash_value.get("total_events_by_platform").cloned().unwrap_or(serde_json::Value::Array(vec![])),        total_events_by_page_url => dash_value.get("total_events_by_page_url").cloned().unwrap_or(serde_json::Value::Array(vec![])),        total_page_views_by_page_url => dash_value.get("total_page_views_by_page_url").cloned().unwrap_or(serde_json::Value::Array(vec![])),        total_events_by_custom_event => dash_value.get("total_events_by_custom_event").cloned().unwrap_or(serde_json::Value::Array(vec![])),        total_session_starts_by_referrer => dash_value.get("total_session_starts_by_referrer").cloned().unwrap_or(serde_json::Value::Array(vec![])),        total_page_views_by_utm_medium => dash_value.get("total_page_views_by_utm_medium").cloned().unwrap_or(serde_json::Value::Array(vec![])),        total_page_views_by_utm_source => dash_value.get("total_page_views_by_utm_source").cloned().unwrap_or(serde_json::Value::Array(vec![])),        total_page_views_by_utm_campaign => dash_value.get("total_page_views_by_utm_campaign").cloned().unwrap_or(serde_json::Value::Array(vec![])),        session_starts_by_country => dash_value.get("session_starts_by_country").cloned().unwrap_or(serde_json::Value::Object(serde_json::Map::new())),        session_starts_by_country_region => dash_value.get("session_starts_by_country_region").cloned().unwrap_or(serde_json::Value::Object(serde_json::Map::new())),        bot_traffic => dash_value.get("bot_traffic").cloned().unwrap_or(serde_json::json!({"total": 0, "top_bots": [], "top_pages": []})),        chart_polyline => &chart_polyline,        chart_label_start => &chart_label_start,        chart_label_end => &chart_label_end,        chart_peak_count => chart_peak_count,        chart_peak_label => &chart_peak_label,        breakdown_totals => &breakdown_totals,        top_countries => &top_countries,        generated_at => &generated_at,    };    // Report exports.    if let Some(fmt) = q.report.as_deref() {        let fmt = if fmt.is_empty() { "pdf" } else { fmt };        if fmt == "md" {            let tmpl = match state.env.get_template("properties/property_report.md") {                Ok(t) => t,                Err(e) => {                    tracing::error!("template property_report.md: {e}");                    return (StatusCode::INTERNAL_SERVER_ERROR, "template error").into_response();                }            };            let body = match tmpl.render(minijinja::context! {                user => crate::templates::UserCtx { is_authenticated: authed },                request => crate::templates::RequestCtx {                    url: String::new(),                    url_root: "/".to_string(),                    base_url: String::new(),                    path: format!("/{property_id}"),                },                now => minijinja::context! { year => chrono::Local::now().year() },                base_url => &state.config.base_url,                collector_id => state.config.proprium_id.map(|u| u.to_string()),                collector_server => &state.config.base_url,                messages => Vec::<()>::new(),                ..extra            }) {                Ok(b) => b,                Err(e) => {                    tracing::error!("render md: {e}");                    return (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response();                }            };            let mut h = axum::http::HeaderMap::new();            h.insert(                axum::http::header::CONTENT_TYPE,                "text/markdown; charset=utf-8".parse().unwrap(),            );            h.insert(                axum::http::header::CONTENT_DISPOSITION,                format!("inline; filename=\"{}.md\"", p.name).parse().unwrap(),            );            return (StatusCode::OK, h, body).into_response();        }        if fmt == "pdf" {            let tmpl = match state.env.get_template("properties/property_print.html") {                Ok(t) => t,                Err(e) => {                    tracing::error!("template property_print.html: {e}");                    return (StatusCode::INTERNAL_SERVER_ERROR, "template error").into_response();                }            };            let html = match tmpl.render(minijinja::context! {                user => crate::templates::UserCtx { is_authenticated: authed },                request => crate::templates::RequestCtx {                    url: String::new(),                    url_root: "/".to_string(),                    base_url: String::new(),                    path: format!("/{property_id}"),                },                now => minijinja::context! { year => chrono::Local::now().year() },                base_url => &state.config.base_url,                collector_id => state.config.proprium_id.map(|u| u.to_string()),                collector_server => &state.config.base_url,                messages => Vec::<()>::new(),                ..extra            }) {                Ok(b) => b,                Err(e) => {                    tracing::error!("render print: {e}");                    return (StatusCode::INTERNAL_SERVER_ERROR, "render error").into_response();                }            };            let server_base = if state.config.base_url.is_empty() {                String::new()            } else {                state.config.base_url.clone()            };            let pdf_res = tokio::task::spawn_blocking(move || crate::pdf::html_to_pdf(&html, &server_base)).await;            match pdf_res {                Ok(Ok(bytes)) => {                    let mut h = axum::http::HeaderMap::new();                    h.insert(axum::http::header::CONTENT_TYPE, "application/pdf".parse().unwrap());                    h.insert(                        axum::http::header::CONTENT_DISPOSITION,                        format!("inline; filename=\"{}.pdf\"", p.name).parse().unwrap(),                    );                    return (StatusCode::OK, h, bytes).into_response();                }                Ok(Err(e)) => {                    tracing::error!("pdf render: {e}");                    return (StatusCode::INTERNAL_SERVER_ERROR, "pdf error").into_response();                }                Err(e) => {                    tracing::error!("pdf join: {e}");                    return (StatusCode::INTERNAL_SERVER_ERROR, "pdf error").into_response();                }            }        }    }    render(        &state,        "properties/property.html",        extra,        authed,        &format!("/{property_id}"),    )    .map(IntoResponse::into_response)    .unwrap_or_else(|e| e.into_response())}/// Toner-friendly SVG polyline points for the print template.fn build_chart_polyline(points: &[serde_json::Value]) -> String {    if points.is_empty() {        return String::new();    }    let counts: Vec<i64> = points        .iter()        .map(|p| p.get("count").and_then(|c| c.as_i64()).unwrap_or(0))        .collect();    let max = *counts.iter().max().unwrap_or(&1);    let max = if max == 0 { 1 } else { max };    let n = counts.len();    let width = 600.0_f64;    let height = 100.0_f64;    let padding = 4.0_f64;    let usable_h = height - 2.0 * padding;    if n == 1 {        let x = width / 2.0;        let y = height - padding - (counts[0] as f64 / max as f64) * usable_h;        return format!("{x:.1},{y:.1}");    }    counts        .iter()        .enumerate()        .map(|(i, c)| {            let x = (i as f64 / (n - 1) as f64) * width;            let y = height - padding - (*c as f64 / max as f64) * usable_h;            format!("{x:.1},{y:.1}")        })        .collect::<Vec<_>>()        .join(" ")}pub async fn property_delete(    State(state): State<AppState>,    AxumPath(property_id): AxumPath<Uuid>,    cookies: Cookies,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    let _ = sqlx::query("DELETE FROM properties WHERE id = ? AND is_protected = 0")        .bind(property_id.as_bytes().to_vec())        .execute(&state.pool)        .await;    Redirect::to("/properties").into_response()}pub async fn property_cards(    State(state): State<AppState>,    AxumPath(property_id): AxumPath<Uuid>,    cookies: Cookies,    body: String,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    // Body is the raw JSON array of {event,value} objects.    let parsed: serde_json::Value =        serde_json::from_str(&body).unwrap_or(serde_json::json!([]));    let payload = parsed.to_string();    let now = chrono::Utc::now().timestamp_millis();    let _ = sqlx::query("UPDATE properties SET custom_cards = ?, updated_at = ? WHERE id = ?")        .bind(payload)        .bind(now)        .bind(property_id.as_bytes().to_vec())        .execute(&state.pool)        .await;    Json(serde_json::json!({"success": true})).into_response()}pub async fn property_public_toggle(    State(state): State<AppState>,    AxumPath(property_id): AxumPath<Uuid>,    cookies: Cookies,) -> Response {    if !is_authenticated(&cookies, &state) {        return Redirect::to("/login").into_response();    }    let now = chrono::Utc::now().timestamp_millis();    let _ = sqlx::query("UPDATE properties SET is_public = 1 - is_public, updated_at = ? WHERE id = ?")        .bind(now)        .bind(property_id.as_bytes().to_vec())        .execute(&state.pool)        .await;    Json(serde_json::json!({"success": true})).into_response()}
renamed analytics/templates/base.html → templates/base.html
@@ -1,24 +1,16 @@{% load static %}<!doctype html><html lang="en"><head>  <meta charset="utf-8">  <meta name="viewport" content="width=device-width, initial-scale=1">  <title>{% block title %}{{ title }}{% endblock %} · Analytics</title>  <meta name="description" content="{% block description %}{{ description }}{% endblock %}">  <link rel="canonical" href="{{ canonical }}">  {% if BASE_URL %}  <base href="{{ BASE_URL }}">  {% endif %}  <link rel="icon" type="image/svg+xml" href="{% url 'favicon' %}">  <title>{% block title %}{{ page.title }}{% endblock %} · Analytics</title>  <meta name="description" content="{% block description %}{{ page.description }}{% endblock %}">  {% if base_url %}<base href="{{ base_url }}">{% endif %}  <link rel="icon" type="image/svg+xml" href="/favicon.ico">  {% block extra_head %}{% endblock %}  <link href="{% static 'base.css' %}" rel="stylesheet">  <link href="{{ vite_asset('static_src/base/index.js', 'css') }}" rel="stylesheet">  {% block extra_css %}{% endblock %}  {% include 'includes/collector.html' %}
@@ -49,26 +41,24 @@      <div class="collapse navbar-collapse" id="navMain">        <ul class="navbar-nav ms-3">          {% if user.is_authenticated %}          <li class="nav-item"><a class="nav-link {% if request.path == '/properties/' %}active{% endif %}" href="/properties/">Properties</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/accounts/profile/' %}active{% endif %}" href="/accounts/profile/">Profile</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/documentation/' %}active{% endif %}" href="/documentation/">Docs</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/properties' %}active{% endif %}" href="/properties">Properties</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/documentation' %}active{% endif %}" href="/documentation">Docs</a></li>          {% else %}          <li class="nav-item"><a class="nav-link {% if request.path == '/' %}active{% endif %}" href="/">Home</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/documentation/' %}active{% endif %}" href="/documentation/">Docs</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/changelog/' %}active{% endif %}" href="/changelog/">Changelog</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/documentation' %}active{% endif %}" href="/documentation">Docs</a></li>          <li class="nav-item"><a class="nav-link {% if request.path == '/changelog' %}active{% endif %}" href="/changelog">Changelog</a></li>          {% endif %}        </ul>        <ul class="navbar-nav ms-auto">          {% if user.is_authenticated %}          <li class="nav-item d-flex align-items-center">            <form method="post" action="{% url 'logout' %}" class="d-inline">              {% csrf_token %}            <form method="post" action="/logout" class="d-inline">              <button type="submit" class="btn btn-sm btn-outline-light">Logout</button>            </form>          </li>          {% else %}          <li class="nav-item d-flex align-items-center">            <a href="/accounts/login/" class="btn btn-sm btn-outline-light">Login</a>            <a href="/login" class="btn btn-sm btn-outline-light">Login</a>          </li>          {% endif %}        </ul>
@@ -104,26 +94,22 @@          <div class="h5 mb-3">// Pages</div>          <ul class="list-unstyled">            <li class="mb-2"><a href="/" class="link-footer">Home</a></li>            <li class="mb-2"><a href="/documentation/" class="link-footer">Documentation</a></li>            <li class="mb-2"><a href="/changelog/" class="link-footer">Changelog</a></li>            <li class="mb-2"><a href="https://github.com/overshard/analytics" class="link-footer" target="_blank">Source</a></li>            <li class="mb-2"><a href="/documentation" class="link-footer">Documentation</a></li>            <li class="mb-2"><a href="/changelog" class="link-footer">Changelog</a></li>          </ul>        </div>        <div class="col-6 col-lg-2">          <div class="h5 mb-3">// Accounts</div>          <div class="h5 mb-3">// Account</div>          <ul class="list-unstyled">            {% if user.is_authenticated %}            <li class="mb-2"><a href="/properties/" class="link-footer">Properties</a></li>            <li class="mb-2"><a href="/accounts/profile/" class="link-footer">Profile</a></li>            <li class="mb-2"><a href="/properties" class="link-footer">Properties</a></li>            <li class="mb-2">              <form method="post" action="{% url 'logout' %}" class="d-inline">                {% csrf_token %}              <form method="post" action="/logout" class="d-inline">                <button type="submit" class="btn btn-link link-footer p-0 align-baseline text-start">Logout</button>              </form>            </li>            {% else %}            <li class="mb-2"><a href="/accounts/login/" class="link-footer">Login</a></li>            <li class="mb-2"><a href="/accounts/password-reset/" class="link-footer">Password reset</a></li>            <li class="mb-2"><a href="/login" class="link-footer">Login</a></li>            {% endif %}          </ul>        </div>
@@ -135,14 +121,9 @@    <div class="container">      <div class="row align-items-center">        <div class="col-sm-6 d-flex align-items-center justify-content-center justify-content-sm-start order-1 order-sm-0">          <small>&copy; {% now 'Y' %} Isaac Bythewood · Some rights reserved · <a href="https://db-ip.com" class="link-footer" target="_blank" rel="noopener">IP Geolocation by DB-IP</a></small>          <small>&copy; {{ now.year }} Isaac Bythewood · Some rights reserved · <a href="https://db-ip.com" class="link-footer" target="_blank" rel="noopener">IP Geolocation by DB-IP</a></small>        </div>        <div class="col-sm-6 d-flex justify-content-center justify-content-sm-end py-3 py-sm-0">          <a href="https://github.com/overshard/analytics" target="_blank" class="footer-bar-link" aria-label="GitHub">            <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">              <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>            </svg>          </a>          <a href="https://isaacbythewood.com/" target="_blank" class="logo logo-sm ms-3" aria-label="Isaac Bythewood">            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">              <rect x="6"  y="38" width="10" height="22" rx="1.5" fill="#6b9e78"/>
@@ -158,7 +139,7 @@  </div>  {% endblock %}  <script type="module" src="{% static 'base.js' %}"></script>  <script type="module" src="{{ vite_asset('static_src/base/index.js') }}"></script>  {% block extra_js %}{% endblock %}</body></html>
added templates/includes/collector.html
@@ -0,0 +1,9 @@{% if collector_id %}<script>  (function(m,e,t,r,i,c,s){m.collectorQueue = m.collectorQueue || r;  m.collectorServer = c; m.collectorId = s; var script = e.createElement(t);  script.src = c + i; e.head.appendChild(script);  })(window,document,'script',[],'{{ vite_asset("static_src/collector/index.js") }}',  '{{ collector_server }}','{{ collector_id }}');</script>{% endif %}
renamed analytics/templates/includes/messages.html → templates/includes/messages.html
@@ -1,6 +1,6 @@{% for message in messages %}<div class="alert {{ message.tags }} alert-dismissible fade show mb-0" role="alert">  {{ message }}<div class="alert alert-{{ message.level }} alert-dismissible fade show mb-0" role="alert">  {{ message.text }}  <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button></div>{% endfor %}
added templates/includes/social.html
@@ -0,0 +1,6 @@<meta property="og:type" content="website"><meta property="og:title" content="{{ page.title }}"><meta property="og:description" content="{{ page.description }}"><meta name="twitter:card" content="summary"><meta name="twitter:title" content="{{ page.title }}"><meta name="twitter:description" content="{{ page.description }}">
renamed pages/templates/pages/changelog.html → templates/pages/changelog.html
@@ -1,5 +1,4 @@{% extends 'base.html' %}{% load static %}{% block extra_head %}
@@ -8,7 +7,7 @@{% block extra_css %}<link href="{% static 'pages.css' %}" rel="stylesheet"><link href="{{ vite_asset('static_src/pages/index.js', 'css') }}" rel="stylesheet">{% endblock %}
@@ -52,7 +51,7 @@          <li>Rework the property dashboard with metric tiles, period-over-period deltas, chart panels, and compact ranked top-lists instead of Bootstrap list groups</li>          <li>Rework the properties listing page with search, live-signal tiles, and a dense row layout</li>          <li>Swap the 📊 emoji favicon for a themed SVG bar-chart mark matching the in-app logo</li>          <li>Add a <code>/documentation/</code> link into the main nav so the collector snippet docs are easier to find</li>          <li>Add a <code>/documentation</code> link into the main nav so the collector snippet docs are easier to find</li>          <li>Restyle the collector-tag modal and accordion docs as terminal-style code blocks</li>        </ul>      </div>
renamed pages/templates/pages/documentation.html → templates/pages/documentation.html
@@ -1,5 +1,4 @@{% extends 'base.html' %}{% load static %}{% block extra_head %}
@@ -8,7 +7,7 @@{% block extra_css %}<link href="{% static 'pages.css' %}" rel="stylesheet"><link href="{{ vite_asset('static_src/pages/index.js', 'css') }}" rel="stylesheet">{% endblock %}
renamed pages/templates/pages/home.html → templates/pages/home.html
@@ -1,5 +1,4 @@{% extends 'base.html' %}{% load static %}{% block extra_head %}
@@ -8,7 +7,7 @@{% block extra_css %}<link href="{% static 'pages.css' %}" rel="stylesheet"><link href="{{ vite_asset('static_src/pages/index.js', 'css') }}" rel="stylesheet">{% endblock %}
@@ -35,8 +34,8 @@          no data leaving your infrastructure.        </p>        <div class="hero-ctas">          <a href="/accounts/login/" class="btn btn-primary">Access dashboard →</a>          <a href="/documentation/" class="btn btn-outline-light">Docs</a>          <a href="/login" class="btn btn-primary">Access dashboard →</a>          <a href="/documentation" class="btn btn-outline-light">Docs</a>          <a href="https://github.com/overshard/analytics" target="_blank" class="btn btn-outline-light">View source</a>        </div>      </div>
@@ -79,7 +78,7 @@        <div class="stat">          <div class="stat-label">collecting since</div>          <div class="stat-value" style="font-size: 1.05rem;">            {% if first_event_created_at %}{{ first_event_created_at|date:"M j, Y" }}{% else %}—{% endif %}            {% if first_event_created_at %}{{ first_event_created_at }}{% else %}—{% endif %}          </div>        </div>      </div>
renamed properties/templates/properties/properties.html → templates/properties/properties.html
@@ -1,23 +1,20 @@{% extends 'base.html' %}{% load static %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>    <li class="breadcrumb-item active" aria-current="page">{{ page.title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="container my-4">  <div class="row align-items-center g-3 mb-4">    <div class="col-md-5">      <div class="section-label mb-1">operator · {{ request.user.username }}</div>      <h1 class="fw-bolder text-white mb-0" style="letter-spacing: -0.01em;">{{ title }}</h1>      <div class="section-label mb-1">operator</div>      <h1 class="fw-bolder text-white mb-0" style="letter-spacing: -0.01em;">{{ page.title }}</h1>      <p class="text-muted mb-0 small mt-1">Every site you're collecting events from, event totals, and seven-day activity state.</p>    </div>    <div class="col-md-7">
@@ -30,7 +27,7 @@          </span>          <input type="text" class="form-control search-input" name="q" id="id_search" placeholder="grep properties..." {% if q %}value="{{ q }}"{% endif %} />        </form>        <button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePropertyAdd" aria-expanded="false" aria-controls="collapsePropertyAdd" onclick="collectorQueue.push({event: 'add_property_toggle'})">        <button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePropertyAdd" aria-expanded="false" aria-controls="collapsePropertyAdd" onclick="window.collectorQueue && collectorQueue.push({event: 'add_property_toggle'})">          + new        </button>      </div>
@@ -41,12 +38,10 @@    <div class="bg-surface border-subtle rounded border p-3">      <div class="section-label mb-2">register property</div>      <p class="text-muted small mb-3">Give it any friendly name. You'll get a collector ID to embed right after you save.</p>      <form method="POST" class="dashboard-toolbar">        {% csrf_token %}        <input type="text" name="name" id="id_name" class="form-control flex-grow-1 {% if form.name.errors %}is-invalid{% endif %}" placeholder="e.g. marketing-site" required>      <form method="POST" action="/properties" class="dashboard-toolbar">        <input type="text" name="name" id="id_name" class="form-control flex-grow-1" placeholder="e.g. marketing-site" required>        <button type="submit" class="btn btn-primary">Add →</button>      </form>      {% if form.errors %}<div class="text-danger small mt-2">{{ form.errors }}</div>{% endif %}    </div>  </div>
@@ -54,25 +49,25 @@    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-green">        <div class="metric-label">total properties</div>        <div class="metric-value">{{ user.total_properties }}</div>        <div class="metric-value">{{ totals.properties }}</div>      </div>    </div>    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-green">        <div class="metric-label">total events</div>        <div class="metric-value">{{ user.total_events }}</div>        <div class="metric-value">{{ totals.events }}</div>      </div>    </div>    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-green">        <div class="metric-label">page views</div>        <div class="metric-value">{{ user.total_page_views }}</div>        <div class="metric-value">{{ totals.page_views }}</div>      </div>    </div>    <div class="col-6 col-md-3">      <div class="metric-tile metric-accent-amber">        <div class="metric-label">sessions</div>        <div class="metric-value">{{ user.total_session_starts }}</div>        <div class="metric-value">{{ totals.sessions }}</div>      </div>    </div>  </div>
@@ -83,7 +78,7 @@      <div class="pr-main">        <div class="pr-title">          <span class="status-dot {% if property.is_active %}is-up{% else %}is-idle{% endif %}" aria-hidden="true"></span>          <a href="{% url 'property' property.id %}" class="text-truncate" onclick="collectorQueue.push({event: 'view_property'})">{{ property.name }}</a>          <a href="/{{ property.id }}" class="text-truncate" onclick="window.collectorQueue && collectorQueue.push({event: 'view_property'})">{{ property.name }}</a>        </div>        <div class="pr-id"><span class="pr-id-k">id</span>{{ property.id }}</div>      </div>
@@ -108,14 +103,14 @@        </div>        <div class="pr-actions">          {% if not property.is_protected %}          <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}" onclick="collectorQueue.push({event: 'delete_property'})">          <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}">            Delete          </button>          <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-labelledby="delete-modal-{{ property.id }}-label" aria-hidden="true">          <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-hidden="true">            <div class="modal-dialog">              <div class="modal-content">                <div class="modal-header">                  <h5 class="modal-title text-white" id="delete-modal-{{ property.id }}-label">Confirm delete</h5>                  <h5 class="modal-title text-white">Confirm delete</h5>                  <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>                </div>                <div class="modal-body">
@@ -123,17 +118,19 @@                </div>                <div class="modal-footer">                  <button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>                  <a href="{% url 'property_delete' property.id %}" class="btn btn-outline-danger">Delete</a>                  <form method="post" action="/properties/{{ property.id }}/delete" class="d-inline">                    <button type="submit" class="btn btn-outline-danger">Delete</button>                  </form>                </div>              </div>            </div>          </div>          {% endif %}          <a href="{% url 'property' property.id %}" class="btn btn-sm btn-outline-light" onclick="collectorQueue.push({event: 'view_property'})">View →</a>          <a href="/{{ property.id }}" class="btn btn-sm btn-outline-light">View →</a>        </div>      </div>    </div>  {% empty %}  {% else %}    <div class="text-center py-5 text-muted">      <div class="section-label mb-3" style="justify-content:center;">no properties yet</div>      <p class="mb-0 small">Click <strong>+ new</strong> above to register your first site.</p>
renamed properties/templates/properties/property.html → templates/properties/property.html
@@ -1,30 +1,26 @@{% extends 'base.html' %}{% load static %}{% block extra_js %}{{ total_events_graph|json_script:"chart-total-events-data" }}{{ total_events_by_browser|json_script:"chart-total-events-by-browser-data" }}{{ total_events_by_screen_size|json_script:"chart-total-events-by-screen-size-data" }}{{ total_events_by_device|json_script:"chart-total-events-by-device-data" }}{{ total_events_by_platform|json_script:"chart-total-events-by-platform-data" }}{{ session_starts_by_country|json_script:"map-session-starts-by-country" }}{{ session_starts_by_country_region|json_script:"map-session-starts-by-country-region" }}<script type="module" src="{% static 'properties.js' %}"></script><script id="chart-total-events-data" type="application/json">{{ total_events_graph | tojson | safe }}</script><script id="chart-total-events-by-browser-data" type="application/json">{{ total_events_by_browser | tojson | safe }}</script><script id="chart-total-events-by-screen-size-data" type="application/json">{{ total_events_by_screen_size | tojson | safe }}</script><script id="chart-total-events-by-device-data" type="application/json">{{ total_events_by_device | tojson | safe }}</script><script id="chart-total-events-by-platform-data" type="application/json">{{ total_events_by_platform | tojson | safe }}</script><script id="map-session-starts-by-country" type="application/json">{{ session_starts_by_country | tojson | safe }}</script><script id="map-session-starts-by-country-region" type="application/json">{{ session_starts_by_country_region | tojson | safe }}</script><script type="module" src="{{ vite_asset('static_src/properties/index.js') }}"></script>{% endblock %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item"><a href="/properties/">Properties</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ title }}</li>    <li class="breadcrumb-item"><a href="/properties">Properties</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ page.title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="bg-deep border-bottom border-subtle py-4">  <div class="container">
@@ -39,8 +35,7 @@      </div>      <div class="col-12 col-lg-6 d-flex flex-wrap gap-2 justify-content-lg-end align-items-center d-print-none">        {% if user.is_authenticated %}        <form id="is-public-form" method="POST" class="d-inline-flex align-items-center gap-2">          {% csrf_token %}        <form id="is-public-form" method="POST" action="/properties/{{ property.id }}/public" class="d-inline-flex align-items-center gap-2">          <span class="toggle-label" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Public properties are viewable by anyone with the URL — no login required.">Visibility</span>          <label class="toggle-pill">            <input type="checkbox" name="is_public" id="is-public-switch" {% if property.is_public %}checked{% endif %}>
@@ -48,20 +43,20 @@            <span class="toggle-pill-seg toggle-pill-public">Public</span>          </label>        </form>        <button type="button" class="btn btn-sm btn-outline-light" data-bs-toggle="modal" data-bs-target="#siteTagModal" onclick="collectorQueue.push({event: 'see_site_tag'})">        <button type="button" class="btn btn-sm btn-outline-light" data-bs-toggle="modal" data-bs-target="#siteTagModal">          Site tag        </button>        <a href="{% url 'property' property.id %}?report" target="_blank" class="btn btn-sm btn-outline-light">Report · pdf</a>        <a href="{% url 'property' property.id %}?report=md" target="_blank" class="btn btn-sm btn-outline-light">Report · md</a>        <a href="/{{ property.id }}?report" target="_blank" class="btn btn-sm btn-outline-light">Report · pdf</a>        <a href="/{{ property.id }}?report=md" target="_blank" class="btn btn-sm btn-outline-light">Report · md</a>        {% if not property.is_protected %}        <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}" onclick="collectorQueue.push({event: 'delete_property'})">        <button type="button" class="btn btn-sm btn-outline-danger" data-bs-toggle="modal" data-bs-target="#delete-modal-{{ property.id }}">          Delete        </button>        <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-labelledby="delete-modal-{{ property.id }}-label" aria-hidden="true">        <div class="modal fade" id="delete-modal-{{ property.id }}" tabindex="-1" aria-hidden="true">          <div class="modal-dialog">            <div class="modal-content">              <div class="modal-header">                <h5 class="modal-title text-white" id="delete-modal-{{ property.id }}-label">Confirm delete</h5>                <h5 class="modal-title text-white">Confirm delete</h5>                <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>              </div>              <div class="modal-body">
@@ -69,7 +64,9 @@              </div>              <div class="modal-footer">                <button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>                <a href="{% url 'property_delete' property.id %}" class="btn btn-outline-danger">Delete</a>                <form method="post" action="/properties/{{ property.id }}/delete" class="d-inline">                  <button type="submit" class="btn btn-outline-danger">Delete</button>                </form>              </div>            </div>          </div>
@@ -93,22 +90,22 @@        <form method="GET">          <div class="date-range-form">            <div class="form-floating">              <input type="date" name="date_start" id="date-start" class="form-control" value="{{ date_start|default:'' }}" />              <input type="date" name="date_start" id="date-start" class="form-control" value="{{ date_start | default('') }}" />              <label for="date-start">Date start</label>            </div>            <div class="form-floating">              <input type="date" name="date_end" id="date-end" class="form-control" value="{{ date_end|default:'' }}" />              <input type="date" name="date_end" id="date-end" class="form-control" value="{{ date_end | default('') }}" />              <label for="date-end">Date end</label>            </div>            <div class="form-floating">              <select name="date_range" id="date-range" class="form-select">                <option value="custom">Custom</option>                <option value="7">7 days</option>                <option value="14">14 days</option>                <option value="28" selected>28 days</option>                <option value="90">3 months</option>                <option value="180">6 months</option>                <option value="365">1 year</option>                <option value="7" {% if date_range == 7 %}selected{% endif %}>7 days</option>                <option value="14" {% if date_range == 14 %}selected{% endif %}>14 days</option>                <option value="28" {% if date_range == 28 %}selected{% endif %}>28 days</option>                <option value="90" {% if date_range == 90 %}selected{% endif %}>3 months</option>                <option value="180" {% if date_range == 180 %}selected{% endif %}>6 months</option>                <option value="365" {% if date_range == 365 %}selected{% endif %}>1 year</option>              </select>              <label for="date-range">Date range</label>            </div>
@@ -122,18 +119,17 @@<div class="container my-4 position-relative">  <div class="d-flex align-items-center justify-content-between mb-2">    <div class="section-label">metrics · period vs previous</div>    {% if user.is_authenticated and custom_events|length > 0 %}    {% if user.is_authenticated and custom_events | length > 0 %}    <div class="dropdown d-print-none">      <button class="btn btn-sm btn-outline-light dropdown-toggle" type="button" id="customCards" data-bs-toggle="dropdown" aria-expanded="false">        Custom cards      </button>      <div class="dropdown-menu dropdown-menu-end p-3" style="width: 300px;" aria-labelledby="customCards">        <form id="custom-card-form" method="POST">          {% csrf_token %}          {% for custom_event in custom_events %}        <form id="custom-card-form" method="POST" action="/properties/{{ property.id }}/cards">          {% for ce in custom_events %}          <div class="form-check form-switch">            <input class="form-check-input" type="checkbox" role="switch" name="{{ custom_event.event }}" id="{{ custom_event.event|slugify }}-switch" {% if custom_event.active %}checked{% endif %}>            <label class="form-check-label small" for="{{ custom_event.event|slugify }}-switch">{{ custom_event.event }}</label>            <input class="form-check-input" type="checkbox" role="switch" name="{{ ce.event }}" id="{{ ce.event }}-switch" {% if ce.active %}checked{% endif %}>            <label class="form-check-label small" for="{{ ce.event }}-switch">{{ ce.event }}</label>          </div>          {% endfor %}        </form>
@@ -143,22 +139,20 @@  </div>  <div class="row g-3">    {% for event_card in event_cards %}    {% for c in event_cards %}    <div class="col-6 col-md-4 col-lg-3">      <div class="metric-tile">        {% with pc=event_card.percent_change|stringformat:"s" %}        {% if event_card.percent_change == 0 %}        {% if c.percent_change == 0 %}        <span class="metric-delta is-flat" title="No change vs previous period">·</span>        {% elif pc|slice:":1" == "-" %}        <span class="metric-delta is-down">{{ event_card.percent_change }}%</span>        {% elif c.percent_change < 0 %}        <span class="metric-delta is-down">{{ c.percent_change }}%</span>        {% else %}        <span class="metric-delta is-up">+{{ event_card.percent_change }}%</span>        <span class="metric-delta is-up">+{{ c.percent_change }}%</span>        {% endif %}        {% endwith %}        <div class="metric-label text-truncate" {% if event_card.help_text %}data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ event_card.help_text }}"{% endif %}>          {{ event_card.name }}        <div class="metric-label text-truncate" {% if c.help_text %}data-bs-toggle="tooltip" data-bs-placement="bottom" title="{{ c.help_text }}"{% endif %}>          {{ c.name }}        </div>        <div class="metric-value">{{ event_card.value }}</div>        <div class="metric-value">{{ c.value }}</div>      </div>    </div>    {% endfor %}
@@ -188,36 +182,20 @@    </div>    <div id="doughnut-graphs" class="col-12 col-md-4">      <div class="chart-panel mb-3">        <div class="chart-panel-header">          <span class="chart-panel-title">device</span>        </div>        <div class="chart-panel-body">          <canvas id="chart-total-events-by-device"></canvas>        </div>        <div class="chart-panel-header"><span class="chart-panel-title">device</span></div>        <div class="chart-panel-body"><canvas id="chart-total-events-by-device"></canvas></div>      </div>      <div class="chart-panel mb-3">        <div class="chart-panel-header">          <span class="chart-panel-title">browser</span>        </div>        <div class="chart-panel-body">          <canvas id="chart-total-events-by-browser"></canvas>        </div>        <div class="chart-panel-header"><span class="chart-panel-title">browser</span></div>        <div class="chart-panel-body"><canvas id="chart-total-events-by-browser"></canvas></div>      </div>      <div class="chart-panel mb-3">        <div class="chart-panel-header">          <span class="chart-panel-title">platform</span>        </div>        <div class="chart-panel-body">          <canvas id="chart-total-events-by-platform"></canvas>        </div>        <div class="chart-panel-header"><span class="chart-panel-title">platform</span></div>        <div class="chart-panel-body"><canvas id="chart-total-events-by-platform"></canvas></div>      </div>      <div class="chart-panel mb-3">        <div class="chart-panel-header">          <span class="chart-panel-title">screen size</span>        </div>        <div class="chart-panel-body">          <canvas id="chart-total-events-by-screen-size"></canvas>        </div>        <div class="chart-panel-header"><span class="chart-panel-title">screen size</span></div>        <div class="chart-panel-body"><canvas id="chart-total-events-by-screen-size"></canvas></div>      </div>    </div>  </div>
@@ -226,17 +204,14 @@<div class="container my-4">  <div class="section-label mb-2">top lists · ranked</div>  <div id="top-lists" class="row g-3">    {% if total_page_views_by_page_url|length > 0 %}    {% if total_page_views_by_page_url | length > 0 %}    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">top pages · page views</span>          <span class="rank-list-col">count</span>        </div>        <div class="rank-list-header"><span class="rank-list-title">top pages · page views</span><span class="rank-list-col">count</span></div>        <div class="rank-list-body">          {% for item in total_page_views_by_page_url %}          <div class="rank-list-row">            <span class="rank-label"><a href="{% url 'property' property.id %}?filter_url={{ item.label|urlencode }}">{{ item.label }}</a></span>            <span class="rank-label"><a href="/{{ property.id }}?filter_url={{ item.label | urlencode }}">{{ item.label }}</a></span>            <span class="rank-count">{{ item.count }}</span>          </div>          {% endfor %}
@@ -244,18 +219,14 @@      </div>    </div>    {% endif %}    {% if total_events_by_page_url|length > 0 %}    {% if total_events_by_page_url | length > 0 %}    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">top pages · all events</span>          <span class="rank-list-col">count</span>        </div>        <div class="rank-list-header"><span class="rank-list-title">top pages · all events</span><span class="rank-list-col">count</span></div>        <div class="rank-list-body">          {% for item in total_events_by_page_url %}          <div class="rank-list-row">            <span class="rank-label"><a href="{% url 'property' property.id %}?filter_url={{ item.label|urlencode }}">{{ item.label }}</a></span>            <span class="rank-label"><a href="/{{ property.id }}?filter_url={{ item.label | urlencode }}">{{ item.label }}</a></span>            <span class="rank-count">{{ item.count }}</span>          </div>          {% endfor %}
@@ -263,96 +234,61 @@      </div>    </div>    {% endif %}    {% if total_session_starts_by_referrer|length > 0 %}    {% if total_session_starts_by_referrer | length > 0 %}    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">top referrers</span>          <span class="rank-list-col">sessions</span>        </div>        <div class="rank-list-header"><span class="rank-list-title">top referrers</span><span class="rank-list-col">sessions</span></div>        <div class="rank-list-body">          {% for item in total_session_starts_by_referrer %}          <div class="rank-list-row">            <span class="rank-label">{{ item.label }}</span>            <span class="rank-count">{{ item.count }}</span>          </div>          <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>          {% endfor %}        </div>      </div>    </div>    {% endif %}    {% if total_events_by_custom_event|length > 0 %}    {% if total_events_by_custom_event | length > 0 %}    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">top custom events</span>          <span class="rank-list-col">count</span>        </div>        <div class="rank-list-header"><span class="rank-list-title">top custom events</span><span class="rank-list-col">count</span></div>        <div class="rank-list-body">          {% for item in total_events_by_custom_event %}          <div class="rank-list-row">            <span class="rank-label">{{ item.label }}</span>            <span class="rank-count">{{ item.count }}</span>          </div>          <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>          {% endfor %}        </div>      </div>    </div>    {% endif %}    {% if total_page_views_by_utm_medium|length > 0 %}    {% if total_page_views_by_utm_medium | length > 0 %}    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">utm medium</span>          <span class="rank-list-col">views</span>        </div>        <div class="rank-list-header"><span class="rank-list-title">utm medium</span><span class="rank-list-col">views</span></div>        <div class="rank-list-body">          {% for item in total_page_views_by_utm_medium %}          <div class="rank-list-row">            <span class="rank-label">{{ item.label }}</span>            <span class="rank-count">{{ item.count }}</span>          </div>          <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>          {% endfor %}        </div>      </div>    </div>    {% endif %}    {% if total_page_views_by_utm_source|length > 0 %}    {% if total_page_views_by_utm_source | length > 0 %}    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">utm source</span>          <span class="rank-list-col">views</span>        </div>        <div class="rank-list-header"><span class="rank-list-title">utm source</span><span class="rank-list-col">views</span></div>        <div class="rank-list-body">          {% for item in total_page_views_by_utm_source %}          <div class="rank-list-row">            <span class="rank-label">{{ item.label }}</span>            <span class="rank-count">{{ item.count }}</span>          </div>          <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>          {% endfor %}        </div>      </div>    </div>    {% endif %}    {% if total_page_views_by_utm_campaign|length > 0 %}    {% if total_page_views_by_utm_campaign | length > 0 %}    <div class="col-12 col-sm-6 col-lg-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">utm campaign</span>          <span class="rank-list-col">views</span>        </div>        <div class="rank-list-header"><span class="rank-list-title">utm campaign</span><span class="rank-list-col">views</span></div>        <div class="rank-list-body">          {% for item in total_page_views_by_utm_campaign %}          <div class="rank-list-row">            <span class="rank-label">{{ item.label }}</span>            <span class="rank-count">{{ item.count }}</span>          </div>          <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>          {% endfor %}        </div>      </div>
@@ -371,37 +307,25 @@        <div class="metric-value">{{ bot_traffic.total }}</div>      </div>    </div>    {% if bot_traffic.top_bots|length > 0 %}    {% if bot_traffic.top_bots | length > 0 %}    <div class="col-12 col-md-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">top bots</span>          <span class="rank-list-col">events</span>        </div>        <div class="rank-list-header"><span class="rank-list-title">top bots</span><span class="rank-list-col">events</span></div>        <div class="rank-list-body">          {% for item in bot_traffic.top_bots %}          <div class="rank-list-row">            <span class="rank-label">{{ item.label }}</span>            <span class="rank-count">{{ item.count }}</span>          </div>          <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>          {% endfor %}        </div>      </div>    </div>    {% endif %}    {% if bot_traffic.top_pages|length > 0 %}    {% if bot_traffic.top_pages | length > 0 %}    <div class="col-12 col-md-4">      <div class="rank-list">        <div class="rank-list-header">          <span class="rank-list-title">top pages · bot hits</span>          <span class="rank-list-col">events</span>        </div>        <div class="rank-list-header"><span class="rank-list-title">top pages · bot hits</span><span class="rank-list-col">events</span></div>        <div class="rank-list-body">          {% for item in bot_traffic.top_pages %}          <div class="rank-list-row">            <span class="rank-label">{{ item.label }}</span>            <span class="rank-count">{{ item.count }}</span>          </div>          <div class="rank-list-row"><span class="rank-label">{{ item.label }}</span><span class="rank-count">{{ item.count }}</span></div>          {% endfor %}        </div>      </div>
@@ -413,22 +337,22 @@  {% endif %}</div><div class="modal fade" id="siteTagModal" tabindex="-1" aria-labelledby="siteTagModalLabel" aria-hidden="true"><div class="modal fade" id="siteTagModal" tabindex="-1" aria-hidden="true">  <div class="modal-dialog modal-lg">    <div class="modal-content">      <div class="modal-header">        <h5 class="modal-title text-white" id="siteTagModalLabel">Collector site tag</h5>        <h5 class="modal-title text-white">Collector site tag</h5>        <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>      </div>      <div class="modal-body">        <p class="text-muted small mb-3">Drop this into the <code class="text-amber">&lt;head&gt;</code> of your site. The collector ID below is scoped to <strong class="text-white">{{ property.name }}</strong>.</p>        <textarea class="codebox" rows="7" readonly><script>  (function(m,e,t,r,i,c,s){m.collectorQueue = m.collectorQueue || r;  m.collectorServer = c; m.collectorId = s; collectorScript = e.createElement(t);  collectorScript.src = c + i; e.head.appendChild(m.collectorScript);  })(window,document,'script',[],'/static/collector.js',  '{{ BASE_URL }}','{{ property.id }}');</script></textarea>        <textarea class="codebox" rows="7" readonly>&lt;script&gt;(function(m,e,t,r,i,c,s){m.collectorQueue = m.collectorQueue || r;m.collectorServer = c; m.collectorId = s; var script = e.createElement(t);script.src = c + i; e.head.appendChild(script);})(window,document,'script',[],'/static/collector.js','{{ collector_server }}','{{ property.id }}');&lt;/script&gt;</textarea>      </div>    </div>  </div>
renamed properties/templates/properties/property_print.html → templates/properties/property_print.html
@@ -1,4 +1,3 @@{% load static %}<!doctype html><html lang="en"><head><meta charset="utf-8">
@@ -179,14 +178,14 @@<div class="header">  <div class="brand">// Analytics · property report</div>  <div class="generated">Generated {% now "Y-m-d H:i" %}</div>  <div class="generated">Generated {{ generated_at }}</div></div><h1>{{ property.name }}</h1><dl class="meta">  <div><dt>Property ID</dt><dd><code>{{ property.id }}</code></dd></div>  <div><dt>Operator</dt><dd>{{ property.user.username }}</dd></div>  <div><dt>Operator</dt><dd>operator</dd></div>  <div><dt>Date range</dt><dd>{{ date_start }} → {{ date_end }} <span style="color:#555;font-weight:400">({{ date_range }} days)</span></dd></div>  <div><dt>Live users · last 30m</dt><dd>{{ total_live_users }}</dd></div></dl>
@@ -201,15 +200,13 @@  <div class="metric">    <div class="label">{{ card.name }}</div>    <div class="value">{{ card.value }}</div>    {% with pc=card.percent_change|stringformat:"s" %}    {% if card.percent_change == 0 %}      <div class="delta flat">no change</div>    {% elif pc|slice:":1" == "-" %}    {% elif card.percent_change < 0 %}      <div class="delta down">{{ card.percent_change }}% vs previous</div>    {% else %}      <div class="delta up">+{{ card.percent_change }}% vs previous</div>    {% endif %}    {% endwith %}  </div>  {% endfor %}</div>
@@ -301,7 +298,7 @@        <tr>          <td>{{ item.label }}</td>          <td class="num">{{ item.count }}</td>          <td class="pct">{% widthratio item.count breakdown_totals.device 100 %}%</td>          <td class="pct">{{ (item.count * 100 / breakdown_totals.device) | int }}%</td>        </tr>        {% endfor %}      </tbody>
@@ -319,7 +316,7 @@        <tr>          <td>{{ item.label }}</td>          <td class="num">{{ item.count }}</td>          <td class="pct">{% widthratio item.count breakdown_totals.browser 100 %}%</td>          <td class="pct">{{ (item.count * 100 / breakdown_totals.browser) | int }}%</td>        </tr>        {% endfor %}      </tbody>
@@ -337,7 +334,7 @@        <tr>          <td>{{ item.label }}</td>          <td class="num">{{ item.count }}</td>          <td class="pct">{% widthratio item.count breakdown_totals.platform 100 %}%</td>          <td class="pct">{{ (item.count * 100 / breakdown_totals.platform) | int }}%</td>        </tr>        {% endfor %}      </tbody>
@@ -355,7 +352,7 @@        <tr>          <td>{{ item.label }}</td>          <td class="num">{{ item.count }}</td>          <td class="pct">{% widthratio item.count breakdown_totals.screen_size 100 %}%</td>          <td class="pct">{{ (item.count * 100 / breakdown_totals.screen_size) | int }}%</td>        </tr>        {% endfor %}      </tbody>
renamed properties/templates/properties/property_report.md → templates/properties/property_report.md
@@ -1,10 +1,10 @@# {{ property.name }}**Property ID:** `{{ property.id }}`**Operator:** {{ property.user.username }}**Operator:** operator**Date range:** {{ date_start }} → {{ date_end }} ({{ date_range }} days){% if filter_url %}**Filter · url:** `{{ filter_url }}`{% endif %}**Generated:** {% now "Y-m-d H:i" %}**Generated:** {{ generated_at }}**Live users (last 30m):** {{ total_live_users }}---
added templates/registration/login.html
@@ -0,0 +1,48 @@{% extends "base.html" %}{% block breadcrumbs %}<nav aria-label="breadcrumb">  <ol class="breadcrumb mb-0">    <li class="breadcrumb-item"><a href="/">Home</a></li>    <li class="breadcrumb-item active" aria-current="page">{{ page.title }}</li>  </ol></nav>{% endblock %}{% block main %}<div class="auth-shell">  <div class="auth-form-col">    <div class="auth-form-wrap">      <div class="section-label mb-2">authenticate</div>      <h1 class="fw-bolder text-white" style="letter-spacing: -0.01em; font-size: 1.9rem;">{{ page.title }}</h1>      <p class="text-muted small mb-4">Single-operator dashboard. Set ANALYTICS_PASSWORD in your .env.</p>      {% if error %}      <div class="alert alert-danger py-2 small">{{ error }}</div>      {% endif %}      <form method="POST" action="/login">        <input type="hidden" name="next" value="{{ next | default('') }}" />        <div class="form-floating mb-3">          <input type="password" class="form-control" name="password" id="id_password" placeholder="Password" required autofocus />          <label for="id_password">Password</label>        </div>        <div class="d-flex justify-content-between align-items-center mt-4">          <button type="submit" class="btn btn-primary">Login →</button>        </div>      </form>    </div>  </div>  <div class="auth-visual-col">    <div style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; padding: 2rem; z-index: 3;">      <div class="terminal-block" style="max-width: 380px; width: 100%;">        <span class="t-line"><span class="t-comment"># analytics · collector</span></span>        <span class="t-line"><span class="t-prompt">operator$</span><span class="t-out">whoami</span></span>        <span class="t-line"><span class="t-val">anonymous</span></span>        <span class="t-line"><span class="t-prompt">operator$</span><span class="t-out">login --mode=interactive</span></span>        <span class="t-line"><span class="t-key">awaiting</span>=<span class="t-val">credentials</span> <span class="t-cursor"></span></span>      </div>    </div>  </div></div>{% endblock %}
deleted uv.lock
@@ -1,873 +0,0 @@version = 1revision = 3requires-python = ">=3.12"[[package]]name = "aiohappyeyeballs"version = "2.6.1"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },][[package]]name = "aiohttp"version = "3.13.5"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "aiohappyeyeballs" },    { name = "aiosignal" },    { name = "attrs" },    { name = "frozenlist" },    { name = "multidict" },    { name = "propcache" },    { name = "yarl" },]sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" },    { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" },    { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" },    { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" },    { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" },    { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" },    { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" },    { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" },    { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" },    { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" },    { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" },    { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" },    { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" },    { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" },    { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" },    { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" },    { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" },    { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" },    { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" },    { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" },    { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" },    { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" },    { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" },    { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" },    { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" },    { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" },    { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" },    { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" },    { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" },    { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" },    { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" },    { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" },    { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" },    { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" },    { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },    { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },    { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },    { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },    { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },    { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },    { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },    { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },    { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },    { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },    { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },    { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },    { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },    { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },    { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },    { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },    { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },    { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },    { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },    { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },    { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },    { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },    { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },    { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },    { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },    { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },    { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },    { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },    { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },    { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },    { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },    { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },    { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },    { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },][[package]]name = "aiosignal"version = "1.4.0"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "frozenlist" },    { name = "typing-extensions", marker = "python_full_version < '3.13'" },]sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },][[package]]name = "analytics"version = "0.0.1"source = { virtual = "." }dependencies = [    { name = "django" },    { name = "dnspython" },    { name = "geoip2" },    { name = "gunicorn" },    { name = "requests" },    { name = "tzdata" },    { name = "user-agents" },    { name = "uvicorn" },    { name = "whitenoise" },][package.metadata]requires-dist = [    { name = "django" },    { name = "dnspython" },    { name = "geoip2" },    { name = "gunicorn" },    { name = "requests" },    { name = "tzdata" },    { name = "user-agents" },    { name = "uvicorn" },    { name = "whitenoise" },][[package]]name = "asgiref"version = "3.11.1"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },][[package]]name = "attrs"version = "26.1.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },][[package]]name = "certifi"version = "2026.2.25"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },][[package]]name = "charset-normalizer"version = "3.4.7"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },    { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },    { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },    { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },    { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },    { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },    { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },    { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },    { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },    { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },    { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },    { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },    { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },    { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },    { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },    { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },    { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },    { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },    { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },    { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },    { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },    { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },    { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },    { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },    { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },    { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },    { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },    { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },    { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },    { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },    { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },    { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },    { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },    { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },    { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },    { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },    { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },    { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },    { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },    { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },    { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },    { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },    { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },    { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },    { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },    { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },    { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },    { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },    { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },    { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },    { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },    { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },    { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },    { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },    { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },    { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },    { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },    { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },    { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },    { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },    { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },    { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },    { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },    { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },    { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },][[package]]name = "click"version = "8.3.2"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "colorama", marker = "sys_platform == 'win32'" },]sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },][[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 = "django"version = "6.0.4"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "asgiref" },    { name = "sqlparse" },    { name = "tzdata", marker = "sys_platform == 'win32'" },]sdist = { url = "https://files.pythonhosted.org/packages/60/b9/4155091ad1788b38563bd77a7258c0834e8c12a7f56f6975deaf54f8b61d/django-6.0.4.tar.gz", hash = "sha256:8cfa2572b3f2768b2e84983cf3c4811877a01edb64e817986ec5d60751c113ac", size = 10907407, upload-time = "2026-04-07T13:55:44.961Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" },][[package]]name = "dnspython"version = "2.8.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },][[package]]name = "frozenlist"version = "1.8.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },    { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },    { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },    { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },    { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },    { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },    { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },    { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },    { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },    { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },    { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },    { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },    { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },    { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },    { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },    { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },    { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },    { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },    { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },    { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },    { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },    { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },    { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },    { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },    { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },    { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },    { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },    { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },    { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },    { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },    { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },    { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },    { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },    { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },    { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },    { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },    { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },    { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },    { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },    { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },    { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },    { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },    { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },    { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },    { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },    { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },    { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },    { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },    { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },    { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },    { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },    { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },    { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },    { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },    { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },    { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },    { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },    { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },    { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },    { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },    { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },    { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },    { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },    { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },    { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },    { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },    { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },    { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },    { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },    { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },    { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },    { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },    { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },    { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },    { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },    { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },    { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },    { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },    { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },    { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },    { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },][[package]]name = "geoip2"version = "5.2.0"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "aiohttp" },    { name = "maxminddb" },    { name = "requests" },]sdist = { url = "https://files.pythonhosted.org/packages/18/70/3d9e87289f79713aaf0fea9df4aa8e68776640fe59beb6299bb214610cfd/geoip2-5.2.0.tar.gz", hash = "sha256:6c9ded1953f8eb16043ed0a8ea20e6e9524ea7b65eb745724e12490aca44ef00", size = 176498, upload-time = "2025-11-20T18:21:08.874Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/dd/d2/d55df737199a52b9d06e742ed2a608c525f0677e40375951372e65714fbd/geoip2-5.2.0-py3-none-any.whl", hash = "sha256:3d1546fd4eb7cad20445d027d2d9e81d3a71c074e019383f30db5d45e2c23320", size = 28991, upload-time = "2025-11-20T18:21:07.178Z" },][[package]]name = "gunicorn"version = "25.3.0"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "packaging" },]sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },][[package]]name = "h11"version = "0.16.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },][[package]]name = "idna"version = "3.11"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },][[package]]name = "maxminddb"version = "3.1.1"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/31/83/bcd7f2e7dfcf601258a4eab92155816218e8f8adf6608d5f7d39da7ba863/maxminddb-3.1.1.tar.gz", hash = "sha256:b19a938c481518f19a2c534ffdcb3bc59582f0fbbdcf9f81ac9adf912a0af686", size = 212410, upload-time = "2026-03-05T18:14:19.601Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/e4/32/331c6c0ce56aacee7f71b9b7ef2438ae74b2d788cd56d4a58cd3be3e6bf8/maxminddb-3.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ae6489a1b7fa4ab9b6ac5979d1eec1eed7cb7ef2f73777ddbb8fb8b9bec094e3", size = 54257, upload-time = "2026-03-05T18:13:10.656Z" },    { url = "https://files.pythonhosted.org/packages/69/6d/4fc324d46b764e870847fc50d7e3b0154dbf165d04d121653066069e3d2c/maxminddb-3.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8f41a51bce83b5bbe4dc31b080787b7d4d83d8efa98778eb6f81df3ad9e98734", size = 36314, upload-time = "2026-03-05T18:13:11.75Z" },    { url = "https://files.pythonhosted.org/packages/22/d5/436054930ccd384f2f6e17aa7689207e9334abbaed63bb573d76dbe0ee8f/maxminddb-3.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9cd4c05d08c22796e83aa54c70feb64121b3eae7257af35fbaced9f5d8d2081", size = 36105, upload-time = "2026-03-05T18:13:13.019Z" },    { url = "https://files.pythonhosted.org/packages/40/a2/bbded436f06c38716163f87d7d92a62f7d305d2f9f7e2e4155f8749a9f1e/maxminddb-3.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c4a402180154393c9c2502c7704b10a32a065661cd84196bbe7ac56869c6a82", size = 103427, upload-time = "2026-03-05T18:13:13.981Z" },    { url = "https://files.pythonhosted.org/packages/c8/a4/8464f1d29007736cfe1e0aa2dd1f3a36f5d7020abb9f14269b614a3f3ae1/maxminddb-3.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14d8b40d8e9b288cee18b8d80a7ba2a28211ce07b9c0e6ce721c5e685e3bf23c", size = 101252, upload-time = "2026-03-05T18:13:15.064Z" },    { url = "https://files.pythonhosted.org/packages/1f/9d/d3c95b64c05e90091ddef22a7a1bcdabe998f36b4a9f4b814382f712d83f/maxminddb-3.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eb2644548114b22d5808972d6b2b77d4c62084966b9a6be3853cd173ff745d5", size = 100587, upload-time = "2026-03-05T18:13:16.077Z" },    { url = "https://files.pythonhosted.org/packages/e0/0a/7e078abe41896d771e2917bc390fcd13186cd9b1b4ef8b451019f1fc342a/maxminddb-3.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a529873e376ada254c68a54d3ad13c8265eeedc5c56bdfdf25f1044b7f4177b", size = 99126, upload-time = "2026-03-05T18:13:18.001Z" },    { url = "https://files.pythonhosted.org/packages/bb/89/f07411f340b70374d1b76b710b059cfc9874e22f596df3f20d26ebbece5b/maxminddb-3.1.1-cp312-cp312-win32.whl", hash = "sha256:9d98641b111eecc047b560d927379dd044bb36c0e399ee794e865b75ee8ef27a", size = 35632, upload-time = "2026-03-05T18:13:20.322Z" },    { url = "https://files.pythonhosted.org/packages/a3/cc/1e42136d8416bbcaa81cdfdb26ec75263f106c0c34bf357ca98eebc394d0/maxminddb-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1ad6d3790ca4b2936f3e4ea971ef8383480fa069ed8ea4e5e6345f049d0e9f7", size = 37332, upload-time = "2026-03-05T18:13:21.363Z" },    { url = "https://files.pythonhosted.org/packages/f9/ed/f3a6b030eef252d4eaa811c87ad3a1d44376de581b11be8246fda1fc3716/maxminddb-3.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:c295f90ce99ce434d6a8477bd94ba4869ca04fe617ac99cea7548f0f6a3e4cd8", size = 34231, upload-time = "2026-03-05T18:13:22.647Z" },    { url = "https://files.pythonhosted.org/packages/91/02/a26cfdb9feed1ec0e66d5adeb37ff1f6bbcf3bb6c26870ec6a4554cacb93/maxminddb-3.1.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:90df0298f6713711ecba893f16ca31c486014e6b1c86611660426e5537cbbe14", size = 37596, upload-time = "2026-03-05T18:13:23.611Z" },    { url = "https://files.pythonhosted.org/packages/8a/05/0bf2b7f2443b71a1c90d9d0772cecd43af392b0d285b20ddae9de9bd57da/maxminddb-3.1.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:42c8dc32b09e5f7c8e2e3d354e5bf48d56bf6c12099dd02df1ceca3f13762d97", size = 38079, upload-time = "2026-03-05T18:13:24.542Z" },    { url = "https://files.pythonhosted.org/packages/d1/cd/a05c211da1c29eaa00f971e30338823872dccf7b6c4935fc71b8d12d6b34/maxminddb-3.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:13a6dba0e696e904773cd9c17a290dcab9ba4a26128811e087c46c2c2f700c13", size = 35280, upload-time = "2026-03-05T18:13:25.541Z" },    { url = "https://files.pythonhosted.org/packages/f7/4e/0f4b34d9d9c5b79712d9616b00f3a683489a0b80ac2b61cb492d7d432977/maxminddb-3.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4d72b373930d95ac00db20a49b43eaac707cc633247f29a4c9a24000187505e9", size = 35823, upload-time = "2026-03-05T18:13:26.526Z" },    { url = "https://files.pythonhosted.org/packages/69/af/509eb9eeb119019f7f86a9e5bfdc1064e9bca590a07808adf9d4152221a6/maxminddb-3.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:afe667a377121a5234778ed0affb9b26bf9d23a0c50551541cc9e38e1c1d1400", size = 54236, upload-time = "2026-03-05T18:13:27.748Z" },    { url = "https://files.pythonhosted.org/packages/91/6e/09949370a413a7b5b791e8784f527bfa216539451c8c9b801ff06e61effe/maxminddb-3.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9a297da0042877a1eef457e238aa4df1707eb7e254aa96ecb1e17e935939a670", size = 36308, upload-time = "2026-03-05T18:13:28.773Z" },    { url = "https://files.pythonhosted.org/packages/8a/01/0bff084d31b4441ec00e8ec84fa961efe5dffd3359d5318a557db8302a09/maxminddb-3.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5c31f5a1e388847b642d8e1b375abfb7b327d51cfd85e9c9f938a3258df7369", size = 36051, upload-time = "2026-03-05T18:13:30.074Z" },    { url = "https://files.pythonhosted.org/packages/5d/31/136f4afed0202d4dbc114668068ba6ef99ab4d5cb3860e8bfc49208965a5/maxminddb-3.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7060e43d0788259b3a9bcc3d604360ebd7b17915300c97f8e254faeb27a70c34", size = 103470, upload-time = "2026-03-05T18:13:31.03Z" },    { url = "https://files.pythonhosted.org/packages/75/1a/2593692d543498959b2836028ff26c36439343a2122f31a71202072f4e62/maxminddb-3.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbb195714caeb419b9a33b07b0b721d620c770210dd955018453fc588a4b7e42", size = 101290, upload-time = "2026-03-05T18:13:32.435Z" },    { url = "https://files.pythonhosted.org/packages/d8/48/883b8961045ea624ac26ed04896bb7e0ccf911488695508bc20bf5cbe1ab/maxminddb-3.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b42f18cf17dfb3ff52ef8c97c41508d45cf23293d04779dce0fe2ca6f146e78", size = 100617, upload-time = "2026-03-05T18:13:33.74Z" },    { url = "https://files.pythonhosted.org/packages/58/0a/bc52b27699601c235ccb973e5fbb0426520c8780f0404dd98e4d09b1ff88/maxminddb-3.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfcc3b074d4cfef4d15474368da70b47707df273cd89e1d6a92549f644852f5c", size = 99145, upload-time = "2026-03-05T18:13:35.2Z" },    { url = "https://files.pythonhosted.org/packages/95/2c/01a3c975622add0f6f04336b400fcf4270c7054c0a35d8622b6bbd4e091b/maxminddb-3.1.1-cp313-cp313-win32.whl", hash = "sha256:3c9d50b5964c00e998ba5fdbad95b62abdf0ed9da5a55eab173f89e38a285e47", size = 35626, upload-time = "2026-03-05T18:13:36.429Z" },    { url = "https://files.pythonhosted.org/packages/b2/27/a64da9ae7dea91d24a12a5649bd62a0eda49ad5ecf184075947971e523ad/maxminddb-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:19be24c36219779e65be57897b36fea340223cafdf3b128f3249e8603be7744d", size = 37330, upload-time = "2026-03-05T18:13:37.355Z" },    { url = "https://files.pythonhosted.org/packages/52/f8/0523374a421b9816da20de42256c7c11e43ce0663decb8b675cf3c0f4561/maxminddb-3.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:6d01a791db367768aa2e15b9c2df5bb4a8d8c11713d872dec5f33dd3e818af26", size = 34222, upload-time = "2026-03-05T18:13:38.355Z" },    { url = "https://files.pythonhosted.org/packages/23/f1/b5069070975602ad14e7898b883dda0b0785dab3c24f5750f5b7fa4e14c3/maxminddb-3.1.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:b2f859ff9ab56b8ce1c2033fdd978273d432ef1b03fbba24dd28d284bc9e2b41", size = 37401, upload-time = "2026-03-05T18:13:39.624Z" },    { url = "https://files.pythonhosted.org/packages/85/b3/1816167dbf9e1373f32548d26c45a74215750680e6a61943355e0d2d157a/maxminddb-3.1.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:40116947ad235692dff2f1590269a844d8623036caf99310a0ea6833b04673ca", size = 37923, upload-time = "2026-03-05T18:13:41.153Z" },    { url = "https://files.pythonhosted.org/packages/b6/f0/6e3a1ea6586fa49f2c438dd48ef9f7e67352729583f9ac6552f0d7d110ad/maxminddb-3.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b83155134842f6dcd06f9ba9b661028a2154debc41bfc6b8d665d67267891153", size = 35248, upload-time = "2026-03-05T18:13:42.122Z" },    { url = "https://files.pythonhosted.org/packages/75/3c/bab279d11b8225e283175be8f6250366d362f131e282723946052c09cc2a/maxminddb-3.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:c2aa82dbb882714071403fe19b301a87a750eb8b58af60fd794afebe9447c0fb", size = 35798, upload-time = "2026-03-05T18:13:43.482Z" },    { url = "https://files.pythonhosted.org/packages/ff/e2/95baf6f117f9bc35de7c83b061b3fd27a49d03e0cda6c4dc5432167ac06e/maxminddb-3.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:65cbeaf809aaeb464e6c89086c45e0e4b9f15b73a28825fea0c570cf6fae18b6", size = 54226, upload-time = "2026-03-05T18:13:44.757Z" },    { url = "https://files.pythonhosted.org/packages/ca/c7/bdcf8ca67c7d93007a4ed512cec61a0e13b43cf9abbb9dfdacd458dff049/maxminddb-3.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ebb79b06f94577c37206a2143e157f4f8e6baf4f4473734b16993e04e0b1fdd7", size = 36368, upload-time = "2026-03-05T18:13:45.778Z" },    { url = "https://files.pythonhosted.org/packages/da/71/a6cba811aa0ce539dff57400435643b2c05e607563f412c9138f50f14d34/maxminddb-3.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fac648527d83c4357f8f060f362a3c24f3aeb94bd458e2221522b7783dac5235", size = 36022, upload-time = "2026-03-05T18:13:47.02Z" },    { url = "https://files.pythonhosted.org/packages/b2/72/c315b8e072663f77f928996fa6b8a084660d4130e00f092943fa9c892016/maxminddb-3.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c57d402cffae339e07224d04315ec1fd923a113dce5a9b2bf5894df8bdcfa", size = 103242, upload-time = "2026-03-05T18:13:48.021Z" },    { url = "https://files.pythonhosted.org/packages/d3/13/7914f150c633e8acbbcde6e37b58a280f79888d5668ae5a50bd3846fde2b/maxminddb-3.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bf4df891b71bb30ef0583effb0e694f610649ace69212def73dd20cc4ae8038", size = 101080, upload-time = "2026-03-05T18:13:49.127Z" },    { url = "https://files.pythonhosted.org/packages/92/a5/d856e34333b80594443a82a384eb383a64c06dd7047a81efb42d129a6412/maxminddb-3.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5fa302b97d205d32e950bb38907a253a76d8a0091f2db5921e6561dbfa8febd1", size = 100611, upload-time = "2026-03-05T18:13:50.667Z" },    { url = "https://files.pythonhosted.org/packages/19/56/bdc5bb7cdac55895aa69b76c4daa39c72c0bb9840439f08ba21e5b9f24c8/maxminddb-3.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3fa33c7e220106a3b9b24e5046c9573079730b62cad61b70897768f319d84323", size = 98959, upload-time = "2026-03-05T18:13:52.088Z" },    { url = "https://files.pythonhosted.org/packages/26/ff/ef98fea99940ef8fc9e55607fcf1afbf37774a6e01815837f735427b29d7/maxminddb-3.1.1-cp314-cp314-win32.whl", hash = "sha256:16fa02b016f8d12e9d78a610a3abfe98510c7db2592cfebaaeb768067c10448f", size = 36291, upload-time = "2026-03-05T18:13:53.085Z" },    { url = "https://files.pythonhosted.org/packages/36/f8/2866267b7728d6877930a22de42e771010f94e5832432a71c1bb0ce1c2e3/maxminddb-3.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:0fa73771e95a1fc4a2c5f3530191473da9eb39ec8301999bd18d9ac6a9becb79", size = 38050, upload-time = "2026-03-05T18:13:54.072Z" },    { url = "https://files.pythonhosted.org/packages/75/82/5b77a81e7ba8c8c83df1daa6cb701490589f5a17238a716d180a105ddf6d/maxminddb-3.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ad144247e94aca2e6f51408ce24ef9184f6bf440affce61090578382cdf69ffc", size = 34785, upload-time = "2026-03-05T18:13:56.232Z" },    { url = "https://files.pythonhosted.org/packages/03/ef/72d6ef4f7acc428b8a8f3910f1acaacc33fcd55cd339029c33a234b6211f/maxminddb-3.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5334cee936368d43f7d99028bb37c864e8862465c6eac838e145d995e30707ec", size = 58136, upload-time = "2026-03-05T18:13:57.19Z" },    { url = "https://files.pythonhosted.org/packages/9d/a7/9f925bc30b77107b32cad2cab23aa5c459544079de62d6bcd3558dfe9a90/maxminddb-3.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff7d739fbefe2529b76dce0362a165795e369ca2d79e04882b42035ab2c16e44", size = 38443, upload-time = "2026-03-05T18:13:58.229Z" },    { url = "https://files.pythonhosted.org/packages/86/8d/80c53840f598d77c01b94c718cc9b66658d677cf09b41f84d92aa04d0f81/maxminddb-3.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5d0709dae06d6f242acc1dd02495b6e1668ea898e22431865f8afc95aa4e7d0", size = 37937, upload-time = "2026-03-05T18:13:59.495Z" },    { url = "https://files.pythonhosted.org/packages/4f/25/6a6f9aaf1af7c8ba83ab84792a7999a442b260bdadd84687800fde56fc4a/maxminddb-3.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e61dca3fd6709817762940f25fc86279957883a00c409612893a168974aba9f1", size = 120046, upload-time = "2026-03-05T18:14:00.48Z" },    { url = "https://files.pythonhosted.org/packages/e9/30/789efe80b43611cafa21bc130ae21c1b09ce7582c52a71d0f393ec69e868/maxminddb-3.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfb73a59ab7a3ccce1b00f048fdab982303253da26324943c831dd5d0f6db99b", size = 116454, upload-time = "2026-03-05T18:14:01.621Z" },    { url = "https://files.pythonhosted.org/packages/81/e2/da72e8a106539b40777ea0c991a1f5b896fe1374fce5d6a0d16977d49a1a/maxminddb-3.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f7e7a0accc4011fd0528c9755010c9d4be06ee116cc0c2aff0ce0f63f792cb41", size = 116395, upload-time = "2026-03-05T18:14:02.689Z" },    { url = "https://files.pythonhosted.org/packages/d4/6d/e18a61f2c2ab08d92801525468be807c5e2f1fed5b817cd35d1c535669db/maxminddb-3.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3f5032043db990f159b0e8e2747468f4665a3b5b345e343f620fe71c91fb2631", size = 113548, upload-time = "2026-03-05T18:14:03.767Z" },    { url = "https://files.pythonhosted.org/packages/04/ba/015d8608e8ab108b6c5e8c252f51155385a70224f439e898ae01cd3aeb67/maxminddb-3.1.1-cp314-cp314t-win32.whl", hash = "sha256:962edb5f23f9e8dfbdfc775d9e452e4017adae2fb12d1b0a955dbddb7747e781", size = 37499, upload-time = "2026-03-05T18:14:04.823Z" },    { url = "https://files.pythonhosted.org/packages/39/67/8da91c3a89530209057e27f314859e17f8d93244b73945d01036e89cf819/maxminddb-3.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6879a4d822b894d1717aeef20b3558fdf1d1f6cf1ce25a7ad46d0a43ed745f63", size = 39557, upload-time = "2026-03-05T18:14:06.334Z" },    { url = "https://files.pythonhosted.org/packages/f1/3e/36c6010a302f822d054fed7ed7009d39dd0ed662c17a01e36dce956787ca/maxminddb-3.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9cc4eb1d2648c0188d7649babe8df1236d22972a753676db3b7487646a65b0c7", size = 35396, upload-time = "2026-03-05T18:14:07.281Z" },][[package]]name = "multidict"version = "6.7.1"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },    { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },    { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },    { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },    { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },    { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },    { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },    { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },    { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },    { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },    { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },    { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },    { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },    { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },    { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },    { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },    { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },    { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },    { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },    { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },    { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },    { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },    { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },    { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },    { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },    { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },    { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },    { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },    { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },    { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },    { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },    { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },    { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },    { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },    { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },    { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },    { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },    { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },    { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },    { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },    { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },    { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },    { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },    { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },    { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },    { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },    { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },    { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },    { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },    { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },    { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },    { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },    { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },    { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },    { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },    { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },    { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },    { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },    { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },    { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },    { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },    { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },    { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },    { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },    { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },    { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },    { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },    { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },    { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },    { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },    { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },    { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },    { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },    { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },    { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },    { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },    { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },    { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },    { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },    { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },    { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },    { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },    { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },    { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },    { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },    { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },    { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },    { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },    { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },    { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },    { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },][[package]]name = "packaging"version = "26.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },][[package]]name = "propcache"version = "0.4.1"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" },    { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" },    { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" },    { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" },    { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" },    { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" },    { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" },    { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" },    { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" },    { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" },    { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" },    { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" },    { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" },    { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" },    { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" },    { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" },    { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" },    { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" },    { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" },    { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" },    { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" },    { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" },    { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" },    { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" },    { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" },    { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" },    { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" },    { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" },    { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" },    { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" },    { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" },    { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" },    { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" },    { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" },    { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" },    { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" },    { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" },    { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" },    { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" },    { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" },    { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" },    { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" },    { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" },    { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" },    { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" },    { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },    { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },    { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },    { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },    { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },    { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },    { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },    { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },    { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },    { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },    { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },    { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },    { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },    { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },    { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },    { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },    { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },    { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },    { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },    { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },    { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },    { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },    { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },    { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },    { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },    { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },    { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },    { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },    { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },    { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },    { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },][[package]]name = "requests"version = "2.33.1"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "certifi" },    { name = "charset-normalizer" },    { name = "idna" },    { name = "urllib3" },]sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },][[package]]name = "sqlparse"version = "0.5.5"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },][[package]]name = "typing-extensions"version = "4.15.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },][[package]]name = "tzdata"version = "2026.1"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },][[package]]name = "ua-parser"version = "1.0.2"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "ua-parser-builtins" },]sdist = { url = "https://files.pythonhosted.org/packages/90/98/5e4b52d772a048af122a6fc5ce365c311efb9f5e79c55fd4fdd7c9f59e83/ua_parser-1.0.2.tar.gz", hash = "sha256:bab404ad42fb37f943107da2f6003ffc79724d11cc95076a7a539513371779da", size = 33239, upload-time = "2026-04-05T20:14:28.229Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/a9/7c/6367995ff57aaa2d9e1055adbaec2519cf5a979780a83a93fdf8c6ec37be/ua_parser-1.0.2-py3-none-any.whl", hash = "sha256:0f8e6d0484af2a9ff804bba5a4fe696e87c028eaba98ad9a7dfae873fef7788a", size = 31219, upload-time = "2026-04-05T20:14:26.913Z" },][[package]]name = "ua-parser-builtins"version = "202603"source = { registry = "https://pypi.org/simple" }wheels = [    { url = "https://files.pythonhosted.org/packages/3e/6f/73a4d37deefb159556d39d654b5bad67b6874d1ad0b20b96fb5a04de3949/ua_parser_builtins-202603-py3-none-any.whl", hash = "sha256:67478397a68fac1a98fd0a31c416ea7c65a719141fc151d0211316f2cd337cc9", size = 89573, upload-time = "2026-03-01T20:50:02.491Z" },][[package]]name = "urllib3"version = "2.6.3"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },][[package]]name = "user-agents"version = "2.2.0"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "ua-parser" },]sdist = { url = "https://files.pythonhosted.org/packages/e3/e1/63c5bfb485a945010c8cbc7a52f85573561737648d36b30394248730a7bc/user-agents-2.2.0.tar.gz", hash = "sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26", size = 9525, upload-time = "2020-08-23T06:01:56.382Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/8f/1c/20bb3d7b2bad56d881e3704131ddedbb16eb787101306887dff349064662/user_agents-2.2.0-py3-none-any.whl", hash = "sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7", size = 9614, upload-time = "2020-08-23T06:01:54.047Z" },][[package]]name = "uvicorn"version = "0.44.0"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "click" },    { name = "h11" },]sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },][[package]]name = "whitenoise"version = "6.12.0"source = { registry = "https://pypi.org/simple" }sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },][[package]]name = "yarl"version = "1.23.0"source = { registry = "https://pypi.org/simple" }dependencies = [    { name = "idna" },    { name = "multidict" },    { name = "propcache" },]sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }wheels = [    { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" },    { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" },    { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" },    { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" },    { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" },    { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" },    { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" },    { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" },    { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" },    { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" },    { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" },    { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" },    { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" },    { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" },    { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" },    { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" },    { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" },    { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" },    { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" },    { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" },    { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" },    { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" },    { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" },    { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" },    { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" },    { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" },    { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" },    { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" },    { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" },    { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" },    { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" },    { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" },    { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" },    { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" },    { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" },    { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" },    { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" },    { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" },    { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" },    { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" },    { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" },    { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" },    { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" },    { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" },    { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" },    { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" },    { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" },    { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" },    { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" },    { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" },    { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" },    { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" },    { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" },    { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" },    { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" },    { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" },    { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" },    { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" },    { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" },    { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" },    { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" },    { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" },    { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" },    { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" },    { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" },    { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" },    { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" },    { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" },    { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" },    { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" },    { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" },    { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" },    { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" },    { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" },    { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" },    { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" },    { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" },    { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" },    { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" },    { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" },    { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" },    { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" },    { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" },    { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" },    { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" },    { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" },    { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" },    { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" },    { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" },    { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" },    { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },]
deleted vite.config.js
@@ -1,34 +0,0 @@import { resolve } from "path";import { defineConfig } from "vite";export default defineConfig({  base: "/static/",  build: {    outDir: resolve(__dirname, "analytics/static"),    emptyOutDir: true,    rollupOptions: {      input: {        base: resolve(__dirname, "analytics/static_src/index.js"),        pages: resolve(__dirname, "pages/static_src/index.js"),        properties: resolve(__dirname, "properties/static_src/index.js"),        collector: resolve(__dirname, "collector/static_src/index.js"),      },      output: {        entryFileNames: "[name].js",        assetFileNames: (assetInfo) => {          if (/\.(png|jpg|gif|svg|webp)$/.test(assetInfo.name)) {            return "images/[name][extname]";          }          return "[name][extname]";        },      },    },  },  css: {    preprocessorOptions: {      scss: {        quietDeps: true,      },    },  },});