From 3b34316fdb1e4c2219721bebcf1ae60ed035cb3a Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Thu, 11 Jun 2026 08:04:03 -0500 Subject: [PATCH 01/16] feat(slackbot): initial commit of slackbot monorepo integration --- .github/actions/setup-node-pnpm/action.yml | 15 +- .release-please-manifest.json | 3 +- .vscode/settings.json | 5 +- AGENTS.md | 16 +- apps/slackbot/.env.local.example | 45 + .../.github/ISSUE_TEMPLATE/bug-report.md | 40 + .../.github/ISSUE_TEMPLATE/feature-request.md | 19 + apps/slackbot/.github/workflows/release.yml | 44 + apps/slackbot/.gitignore | 160 + apps/slackbot/.pre-commit-config.yaml | 36 + apps/slackbot/.python-version | 1 + apps/slackbot/.vscode/launch.json | 15 + apps/slackbot/CHANGELOG.md | 317 ++ apps/slackbot/Dockerfile | 9 + apps/slackbot/README.md | 81 + apps/slackbot/__init__.py | 0 apps/slackbot/app_manifest.template.json | 155 + apps/slackbot/app_startup.sh | 119 + apps/slackbot/application/__init__.py | 0 apps/slackbot/application/ao/__init__.py | 13 + apps/slackbot/application/ao/repository.py | 48 + apps/slackbot/application/ao/service.py | 65 + .../application/event_instance/__init__.py | 26 + .../application/event_instance/repository.py | 83 + .../application/event_instance/service.py | 141 + .../application/event_tag/__init__.py | 10 + .../application/event_tag/repository.py | 36 + .../slackbot/application/event_tag/service.py | 39 + .../application/event_type/__init__.py | 10 + .../application/event_type/repository.py | 36 + .../application/event_type/service.py | 39 + .../slackbot/application/location/__init__.py | 17 + .../application/location/repository.py | 60 + apps/slackbot/application/location/service.py | 90 + .../slackbot/application/position/__init__.py | 19 + .../application/position/repository.py | 47 + apps/slackbot/application/position/service.py | 48 + apps/slackbot/application/series/__init__.py | 29 + .../slackbot/application/series/repository.py | 66 + apps/slackbot/application/series/service.py | 119 + .../assets/Slackblast-Welcome-Demo.png | Bin 0 -> 26003 bytes apps/slackbot/assets/backblast_demo.png | Bin 0 -> 46454 bytes apps/slackbot/assets/local_setup.png | Bin 0 -> 39250 bytes apps/slackbot/docs/API_MIGRATION.md | 562 +++ apps/slackbot/docs/API_REFERENCE.md | 270 ++ apps/slackbot/docs/ARCHITECTURE.md | 248 + apps/slackbot/docs/TESTING_STRATEGY.md | 1785 +++++++ apps/slackbot/docs/copilot-instructions.md | 176 + apps/slackbot/features/__init__.py | 0 apps/slackbot/features/achievements.py | 1058 +++++ apps/slackbot/features/backblast.py | 1391 ++++++ apps/slackbot/features/calendar/__init__.py | 76 + apps/slackbot/features/calendar/ao.py | 361 ++ apps/slackbot/features/calendar/config.py | 265 ++ .../features/calendar/event_instance.py | 708 +++ .../features/calendar/event_preblast.py | 1158 +++++ apps/slackbot/features/calendar/event_tag.py | 268 ++ apps/slackbot/features/calendar/event_type.py | 270 ++ apps/slackbot/features/calendar/home.py | 763 +++ apps/slackbot/features/calendar/location.py | 454 ++ .../features/calendar/nearby_events.py | 508 ++ apps/slackbot/features/calendar/series.py | 619 +++ apps/slackbot/features/canvas.py | 126 + apps/slackbot/features/config.py | 220 + apps/slackbot/features/connect.py | 402 ++ apps/slackbot/features/custom_fields.py | 256 + apps/slackbot/features/db_admin.py | 460 ++ apps/slackbot/features/downrange.py | 897 ++++ apps/slackbot/features/emergency.py | 292 ++ apps/slackbot/features/help.py | 102 + apps/slackbot/features/paxminer_mapping.py | 217 + apps/slackbot/features/positions.py | 377 ++ apps/slackbot/features/region.py | 230 + apps/slackbot/features/reporting.py | 114 + apps/slackbot/features/special_events.py | 91 + apps/slackbot/features/strava.py | 424 ++ apps/slackbot/features/user.py | 287 ++ apps/slackbot/features/weaselbot.py | 132 + apps/slackbot/features/welcome.py | 103 + apps/slackbot/infrastructure/__init__.py | 0 .../infrastructure/api_client/__init__.py | 34 + .../api_client/ao_repository.py | 131 + .../infrastructure/api_client/client.py | 118 + .../api_client/event_instance_repository.py | 307 ++ .../api_client/event_tag_repository.py | 86 + .../api_client/event_type_repository.py | 95 + .../infrastructure/api_client/exceptions.py | 14 + .../api_client/location_repository.py | 147 + .../api_client/position_repository.py | 140 + .../api_client/series_repository.py | 277 ++ apps/slackbot/main.py | 215 + apps/slackbot/package.json | 10 + apps/slackbot/pyproject.toml | 103 + apps/slackbot/scripts/Dockerfile | 49 + apps/slackbot/scripts/README.md | 50 + apps/slackbot/scripts/__init__.py | 36 + apps/slackbot/scripts/auto_preblast_send.py | 173 + apps/slackbot/scripts/award_achievements.py | 802 ++++ apps/slackbot/scripts/backblast_reminders.py | 226 + apps/slackbot/scripts/calendar_images.py | 589 +++ apps/slackbot/scripts/cloudbuild.yaml | 39 + apps/slackbot/scripts/home_region_nudge.py | 300 ++ apps/slackbot/scripts/hourly_runner.py | 110 + apps/slackbot/scripts/monthly_reporting.py | 796 ++++ apps/slackbot/scripts/preblast_reminders.py | 187 + apps/slackbot/scripts/q_lineups.py | 375 ++ apps/slackbot/scripts/requirements.txt | 191 + apps/slackbot/scripts/update_slack_users.py | 121 + .../slackbot/scripts/update_special_events.py | 86 + .../tests/application/series/test_service.py | 198 + .../tests/features/calendar/test_ao.py | 359 ++ .../features/calendar/test_event_instance.py | 628 +++ .../tests/features/calendar/test_event_tag.py | 392 ++ .../features/calendar/test_event_type.py | 372 ++ .../tests/features/calendar/test_location.py | 369 ++ .../tests/features/calendar/test_series.py | 407 ++ .../slackbot/tests/features/test_positions.py | 399 ++ .../api_client/test_ao_repository.py | 227 + .../infrastructure/api_client/test_client.py | 162 + .../api_client/test_event_tag_repository.py | 130 + .../api_client/test_event_type_repository.py | 242 + .../api_client/test_location_repository.py | 258 + .../api_client/test_position_repository.py | 235 + .../api_client/test_series_repository.py | 353 ++ .../tests/utilities/test_helper_functions.py | 10 + apps/slackbot/utilities/__init__.py | 0 apps/slackbot/utilities/bot_logger.py | 142 + apps/slackbot/utilities/builders.py | 160 + apps/slackbot/utilities/constants.py | 289 ++ apps/slackbot/utilities/database/__init__.py | 0 .../utilities/database/orm/__init__.py | 80 + apps/slackbot/utilities/database/orm/views.py | 112 + .../utilities/database/special_queries.py | 555 +++ apps/slackbot/utilities/default_help.json | 191 + apps/slackbot/utilities/helper_functions.py | 918 ++++ apps/slackbot/utilities/options.py | 119 + apps/slackbot/utilities/routing.py | 285 ++ apps/slackbot/utilities/sendmail.py | 95 + apps/slackbot/utilities/slack/__init__.py | 0 apps/slackbot/utilities/slack/actions.py | 386 ++ apps/slackbot/utilities/slack/forms.py | 1048 +++++ apps/slackbot/utilities/slack/orm.py | 978 ++++ apps/slackbot/utilities/slack/sdk_orm.py | 253 + commitlint.config.mjs | 61 +- docs/LOCAL_DEV_DOCKER.md | 20 +- package.json | 1 + packages/db-python/.env.example | 6 + packages/db-python/.gitignore | 5 + packages/db-python/README.md | 72 + packages/db-python/f3_data_models/__init__.py | 0 packages/db-python/f3_data_models/models.py | 1709 +++++++ packages/db-python/f3_data_models/testing.py | 24 + packages/db-python/f3_data_models/utils.py | 394 ++ packages/db-python/pyproject.toml | 61 + packages/db/src/local-seed-lib/data.ts | 6 + pnpm-lock.yaml | 2 + pyproject.toml | 10 + release-please-config.json | 5 + scripts/local-setup.sh | 2 +- uv.lock | 4132 +++++++++++++++++ 160 files changed, 40406 insertions(+), 54 deletions(-) create mode 100644 apps/slackbot/.env.local.example create mode 100644 apps/slackbot/.github/ISSUE_TEMPLATE/bug-report.md create mode 100644 apps/slackbot/.github/ISSUE_TEMPLATE/feature-request.md create mode 100644 apps/slackbot/.github/workflows/release.yml create mode 100644 apps/slackbot/.gitignore create mode 100644 apps/slackbot/.pre-commit-config.yaml create mode 100644 apps/slackbot/.python-version create mode 100644 apps/slackbot/.vscode/launch.json create mode 100644 apps/slackbot/CHANGELOG.md create mode 100644 apps/slackbot/Dockerfile create mode 100644 apps/slackbot/README.md create mode 100644 apps/slackbot/__init__.py create mode 100644 apps/slackbot/app_manifest.template.json create mode 100755 apps/slackbot/app_startup.sh create mode 100644 apps/slackbot/application/__init__.py create mode 100644 apps/slackbot/application/ao/__init__.py create mode 100644 apps/slackbot/application/ao/repository.py create mode 100644 apps/slackbot/application/ao/service.py create mode 100644 apps/slackbot/application/event_instance/__init__.py create mode 100644 apps/slackbot/application/event_instance/repository.py create mode 100644 apps/slackbot/application/event_instance/service.py create mode 100644 apps/slackbot/application/event_tag/__init__.py create mode 100644 apps/slackbot/application/event_tag/repository.py create mode 100644 apps/slackbot/application/event_tag/service.py create mode 100644 apps/slackbot/application/event_type/__init__.py create mode 100644 apps/slackbot/application/event_type/repository.py create mode 100644 apps/slackbot/application/event_type/service.py create mode 100644 apps/slackbot/application/location/__init__.py create mode 100644 apps/slackbot/application/location/repository.py create mode 100644 apps/slackbot/application/location/service.py create mode 100644 apps/slackbot/application/position/__init__.py create mode 100644 apps/slackbot/application/position/repository.py create mode 100644 apps/slackbot/application/position/service.py create mode 100644 apps/slackbot/application/series/__init__.py create mode 100644 apps/slackbot/application/series/repository.py create mode 100644 apps/slackbot/application/series/service.py create mode 100755 apps/slackbot/assets/Slackblast-Welcome-Demo.png create mode 100755 apps/slackbot/assets/backblast_demo.png create mode 100755 apps/slackbot/assets/local_setup.png create mode 100644 apps/slackbot/docs/API_MIGRATION.md create mode 100644 apps/slackbot/docs/API_REFERENCE.md create mode 100644 apps/slackbot/docs/ARCHITECTURE.md create mode 100644 apps/slackbot/docs/TESTING_STRATEGY.md create mode 100644 apps/slackbot/docs/copilot-instructions.md create mode 100644 apps/slackbot/features/__init__.py create mode 100644 apps/slackbot/features/achievements.py create mode 100644 apps/slackbot/features/backblast.py create mode 100644 apps/slackbot/features/calendar/__init__.py create mode 100644 apps/slackbot/features/calendar/ao.py create mode 100644 apps/slackbot/features/calendar/config.py create mode 100644 apps/slackbot/features/calendar/event_instance.py create mode 100644 apps/slackbot/features/calendar/event_preblast.py create mode 100644 apps/slackbot/features/calendar/event_tag.py create mode 100644 apps/slackbot/features/calendar/event_type.py create mode 100644 apps/slackbot/features/calendar/home.py create mode 100644 apps/slackbot/features/calendar/location.py create mode 100644 apps/slackbot/features/calendar/nearby_events.py create mode 100644 apps/slackbot/features/calendar/series.py create mode 100644 apps/slackbot/features/canvas.py create mode 100644 apps/slackbot/features/config.py create mode 100644 apps/slackbot/features/connect.py create mode 100644 apps/slackbot/features/custom_fields.py create mode 100644 apps/slackbot/features/db_admin.py create mode 100644 apps/slackbot/features/downrange.py create mode 100644 apps/slackbot/features/emergency.py create mode 100644 apps/slackbot/features/help.py create mode 100644 apps/slackbot/features/paxminer_mapping.py create mode 100644 apps/slackbot/features/positions.py create mode 100644 apps/slackbot/features/region.py create mode 100644 apps/slackbot/features/reporting.py create mode 100644 apps/slackbot/features/special_events.py create mode 100644 apps/slackbot/features/strava.py create mode 100644 apps/slackbot/features/user.py create mode 100644 apps/slackbot/features/weaselbot.py create mode 100644 apps/slackbot/features/welcome.py create mode 100644 apps/slackbot/infrastructure/__init__.py create mode 100644 apps/slackbot/infrastructure/api_client/__init__.py create mode 100644 apps/slackbot/infrastructure/api_client/ao_repository.py create mode 100644 apps/slackbot/infrastructure/api_client/client.py create mode 100644 apps/slackbot/infrastructure/api_client/event_instance_repository.py create mode 100644 apps/slackbot/infrastructure/api_client/event_tag_repository.py create mode 100644 apps/slackbot/infrastructure/api_client/event_type_repository.py create mode 100644 apps/slackbot/infrastructure/api_client/exceptions.py create mode 100644 apps/slackbot/infrastructure/api_client/location_repository.py create mode 100644 apps/slackbot/infrastructure/api_client/position_repository.py create mode 100644 apps/slackbot/infrastructure/api_client/series_repository.py create mode 100644 apps/slackbot/main.py create mode 100644 apps/slackbot/package.json create mode 100644 apps/slackbot/pyproject.toml create mode 100644 apps/slackbot/scripts/Dockerfile create mode 100644 apps/slackbot/scripts/README.md create mode 100644 apps/slackbot/scripts/__init__.py create mode 100644 apps/slackbot/scripts/auto_preblast_send.py create mode 100755 apps/slackbot/scripts/award_achievements.py create mode 100644 apps/slackbot/scripts/backblast_reminders.py create mode 100644 apps/slackbot/scripts/calendar_images.py create mode 100644 apps/slackbot/scripts/cloudbuild.yaml create mode 100644 apps/slackbot/scripts/home_region_nudge.py create mode 100644 apps/slackbot/scripts/hourly_runner.py create mode 100644 apps/slackbot/scripts/monthly_reporting.py create mode 100644 apps/slackbot/scripts/preblast_reminders.py create mode 100644 apps/slackbot/scripts/q_lineups.py create mode 100644 apps/slackbot/scripts/requirements.txt create mode 100644 apps/slackbot/scripts/update_slack_users.py create mode 100644 apps/slackbot/scripts/update_special_events.py create mode 100644 apps/slackbot/tests/application/series/test_service.py create mode 100644 apps/slackbot/tests/features/calendar/test_ao.py create mode 100644 apps/slackbot/tests/features/calendar/test_event_instance.py create mode 100644 apps/slackbot/tests/features/calendar/test_event_tag.py create mode 100644 apps/slackbot/tests/features/calendar/test_event_type.py create mode 100644 apps/slackbot/tests/features/calendar/test_location.py create mode 100644 apps/slackbot/tests/features/calendar/test_series.py create mode 100644 apps/slackbot/tests/features/test_positions.py create mode 100644 apps/slackbot/tests/infrastructure/api_client/test_ao_repository.py create mode 100644 apps/slackbot/tests/infrastructure/api_client/test_client.py create mode 100644 apps/slackbot/tests/infrastructure/api_client/test_event_tag_repository.py create mode 100644 apps/slackbot/tests/infrastructure/api_client/test_event_type_repository.py create mode 100644 apps/slackbot/tests/infrastructure/api_client/test_location_repository.py create mode 100644 apps/slackbot/tests/infrastructure/api_client/test_position_repository.py create mode 100644 apps/slackbot/tests/infrastructure/api_client/test_series_repository.py create mode 100644 apps/slackbot/tests/utilities/test_helper_functions.py create mode 100644 apps/slackbot/utilities/__init__.py create mode 100644 apps/slackbot/utilities/bot_logger.py create mode 100644 apps/slackbot/utilities/builders.py create mode 100644 apps/slackbot/utilities/constants.py create mode 100644 apps/slackbot/utilities/database/__init__.py create mode 100644 apps/slackbot/utilities/database/orm/__init__.py create mode 100644 apps/slackbot/utilities/database/orm/views.py create mode 100644 apps/slackbot/utilities/database/special_queries.py create mode 100644 apps/slackbot/utilities/default_help.json create mode 100644 apps/slackbot/utilities/helper_functions.py create mode 100644 apps/slackbot/utilities/options.py create mode 100644 apps/slackbot/utilities/routing.py create mode 100644 apps/slackbot/utilities/sendmail.py create mode 100644 apps/slackbot/utilities/slack/__init__.py create mode 100644 apps/slackbot/utilities/slack/actions.py create mode 100644 apps/slackbot/utilities/slack/forms.py create mode 100644 apps/slackbot/utilities/slack/orm.py create mode 100644 apps/slackbot/utilities/slack/sdk_orm.py create mode 100644 packages/db-python/.env.example create mode 100644 packages/db-python/.gitignore create mode 100644 packages/db-python/README.md create mode 100644 packages/db-python/f3_data_models/__init__.py create mode 100644 packages/db-python/f3_data_models/models.py create mode 100644 packages/db-python/f3_data_models/testing.py create mode 100755 packages/db-python/f3_data_models/utils.py create mode 100644 packages/db-python/pyproject.toml create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml index 69dbee81..c3f83092 100644 --- a/.github/actions/setup-node-pnpm/action.yml +++ b/.github/actions/setup-node-pnpm/action.yml @@ -1,12 +1,19 @@ -name: Setup Node and pnpm +name: Setup Node, pnpm, Python, and uv description: >- - Install pnpm and Node (pinned via .nvmrc), restore the pnpm store cache, and - install workspace dependencies from the frozen lockfile. Shared by every CI - job so the toolchain setup can never drift between jobs. + Install Python and uv, then pnpm and Node (pinned via .nvmrc), restore the + pnpm store cache, and install workspace dependencies from the frozen + lockfile. Shared by every CI job so the toolchain setup can never drift + between jobs. runs: using: composite steps: + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e5b1338b..bf08d1fe 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -2,5 +2,6 @@ "apps/me": "1.3.0", "apps/admin": "1.2.1", "apps/auth": "1.2.0", - "apps/homepage": "1.0.0" + "apps/homepage": "1.0.0", + "apps/slackbot": "1.13.0" } diff --git a/.vscode/settings.json b/.vscode/settings.json index 2797d923..e9bb9e4d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,5 +20,8 @@ "next/router.d.ts", "next/dist/client/router.d.ts" ], - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "[toml]": { + "editor.defaultFormatter": "tamasfe.even-better-toml" + } } diff --git a/AGENTS.md b/AGENTS.md index 4f7f31fd..0011345f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ that app's `AGENTS.md`. ## Project Structure & Module Organization - Use Node >=24.14 (see `.nvmrc`), pnpm 11, and Turborepo for workspace orchestration. -- `apps/` holds the deployable Next.js apps: `map` (the Next.js 15 map UI, port 3000), `admin`, `api`, `auth`, `homepage`, and `me`. +- `apps/` holds the deployable apps: Next.js apps `map` (port 3000), `admin`, `api`, `auth`, `homepage`, and `me`; plus the Python Slack app `slackbot` (port 3006). - Shared code is organized in `packages/`: `api` (oRPC routers), `auth` (auth helpers), `db` (Drizzle schema/migrations), `env` (environment validation), `mail` (transactional email), `shared` (utilities), `sso` (single sign-on helpers), `storage` (object storage), `ui` (shared components), and `validators` (Zod schemas). - Configuration files are in `tooling/`; Turbo generators live in `turbo/`. @@ -40,7 +40,7 @@ that app's `AGENTS.md`. - **First-time setup:** `pnpm local:setup` — copies per-directory `.env` files, starts Docker services, runs migrations, and seeds the database. See [docs/LOCAL_DEV_DOCKER.md](docs/LOCAL_DEV_DOCKER.md) for the full guide. - **Docker services:** `pnpm docker:up` to start (Postgres, Adminer, GCS emulator, Mailpit), `pnpm docker:down` to stop. - Install dependencies with `pnpm install`. You can scope installations with `--filter `. -- Start development: `pnpm dev --filter f3-nation-map` for the map app, or `pnpm dev` to run all watch tasks. +- Start development: `pnpm dev --filter f3-nation-map` for the map app, `pnpm dev --filter slack-bot` for slackbot only, or `pnpm dev` to run all watch tasks. - Each app and `packages/env` has its own `.env` file (copied from `.env.local.example` by `pnpm local:setup`). Never commit `.env` files. - Build with `pnpm build` (or `pnpm build --filter apps/map`), and start production with `pnpm -C apps/map start`. - Code quality: always run `pnpm lint` (or `pnpm lint --filter apps/map`) and `pnpm format:fix` to ensure your code passes all lint and formatting checks. Also run `pnpm typecheck` to validate types. @@ -105,13 +105,13 @@ Use standard Conventional Commit types: `feat`, `fix`, `chore`, `docs`, `style`, Scopes are defined in `commitlint.config.mjs` and map to monorepo packages: -| Category | Scopes | -| --------------- | ----------------------------------------------------------------- | -| Apps | `admin`, `homepage`, `map`, `me` | -| Apps & Packages | `api`, `auth` (exist in both `apps/` and `packages/`) | +| Category | Scopes | +| --------------- | ------------------------------------------------------------------- | +| Apps | `admin`, `homepage`, `map`, `me` | +| Apps & Packages | `api`, `auth` (exist in both `apps/` and `packages/`) | | Packages | `db`, `env`, `mail`, `shared`, `sso`, `storage`, `ui`, `validators` | -| Tooling | `eslint`, `prettier`, `tsconfig`, `scripts`, `github`, `tailwind` | -| Cross-cutting | `deps`, `ci`, `repo`, `release`, `dev` (used by Release Please) | +| Tooling | `eslint`, `prettier`, `tsconfig`, `scripts`, `github`, `tailwind` | +| Cross-cutting | `deps`, `ci`, `repo`, `release`, `dev` (used by Release Please) | **Choosing a scope:** diff --git a/apps/slackbot/.env.local.example b/apps/slackbot/.env.local.example new file mode 100644 index 00000000..3c1bfdd6 --- /dev/null +++ b/apps/slackbot/.env.local.example @@ -0,0 +1,45 @@ +# All variables are plain KEY=VALUE (no "export"). Docker Compose and tools that read .env expect this format. + +# Slack - these come from the Slack App web UI +SLACK_SIGNING_SECRET=your-signing-secret +SLACK_BOT_TOKEN=your-bot-token +SLACK_APP_TOKEN=your-app-token + +# Database (local dev defaults, you are welcome to change these) +DATABASE_HOST=localhost +DATABASE_PORT=5433 +DATABASE_USER=f3local +DATABASE_PASSWORD=f3local +DATABASE_SCHEMA=f3nation +SQL_ECHO=False + +# "secret menu" admin password, use after /f3-nation-settings command +DB_ADMIN_PASSWORD=set-yourself + +# Used for encrypting email password +PASSWORD_ENCRYPT_KEY=set-yourself + +# Only needed if testing Strava +STRAVA_CLIENT_ID= +STRAVA_CLIENT_SECRET= + +# Only needed if testing AWS S3 image uploads +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= + +# Only needed if testing "legacy" mode or paxminer migrations +PAXMINER_DATABASE_HOST= +PAXMINER_DATABASE_USER= +PAXMINER_DATABASE_PASSWORD= +PAXMINER_DATABASE_SCHEMA= + +# F3 Nation API (required for API-backed features like event tags) +F3_API_KEY=your-api-key +F3_API_BASE_URL=https://api.f3nation.com + +# Misc +STATS_URL=https://pax-vault.vercel.app +LOCAL_DEVELOPMENT=true +ENABLE_DEBUGGING=false +SOCKET_MODE=true +LOCAL_HTTP_PORT=3006 \ No newline at end of file diff --git a/apps/slackbot/.github/ISSUE_TEMPLATE/bug-report.md b/apps/slackbot/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 00000000..9b77ea71 --- /dev/null +++ b/apps/slackbot/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,40 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "" +labels: "" +assignees: "" +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] + +**Smartphone (please complete the following information):** + +- Device: [e.g. iPhone6] +- OS: [e.g. iOS8.1] +- Browser [e.g. stock browser, safari] +- Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/apps/slackbot/.github/ISSUE_TEMPLATE/feature-request.md b/apps/slackbot/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 00000000..2bc5d5f7 --- /dev/null +++ b/apps/slackbot/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "" +labels: "" +assignees: "" +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/apps/slackbot/.github/workflows/release.yml b/apps/slackbot/.github/workflows/release.yml new file mode 100644 index 00000000..ee18ca89 --- /dev/null +++ b/apps/slackbot/.github/workflows/release.yml @@ -0,0 +1,44 @@ +name: Release and Sync + +on: + push: + branches: + - prod + +jobs: + release: + runs-on: ubuntu-latest + concurrency: release + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} # See Note below + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Python Semantic Release + uses: python-semantic-release/python-semantic-release@master + with: + github_token: ${{ secrets.RELEASE_TOKEN }} + + - name: Sync Prod back to Main + # Only run this if a new release was actually created + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git fetch origin main + git checkout main + git merge prod --no-edit -m "chore: sync prod to main [skip ci]" + git push origin main diff --git a/apps/slackbot/.gitignore b/apps/slackbot/.gitignore new file mode 100644 index 00000000..ed0de13c --- /dev/null +++ b/apps/slackbot/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.env-prod +.env.copy +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +.vscode/settings.json + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Ignore Azure App Service configuration +# In Azure Portal, go to Deployment Center and integrate to Github and it will +# automatically put the published file, e.g. main_.yml into +# your github repo +main_*.yml + +aws-env.sh +testing_code.py +env.json + +.aws-sam/ +.aws-sam-old/ + +# manifest files +app_manifest.yaml +app_manifest.json +lt_output.txt \ No newline at end of file diff --git a/apps/slackbot/.pre-commit-config.yaml b/apps/slackbot/.pre-commit-config.yaml new file mode 100644 index 00000000..59472dd5 --- /dev/null +++ b/apps/slackbot/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +repos: + - repo: https://github.com/python-poetry/poetry + rev: 2.2.1 + hooks: + - id: poetry-check + - id: poetry-lock + + - repo: https://github.com/python-poetry/poetry-plugin-export + rev: 1.8.0 + hooks: + - id: poetry-export + args: + [ + "--without", + "dev", + "-f", + "requirements.txt", + "-o", + "requirements.txt", + "--without-hashes", + ] + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.15.9 + hooks: + # Run the linter. + - id: ruff + args: [--fix] + # Run the formatter. + - id: ruff-format + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v4.4.0 + hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [] diff --git a/apps/slackbot/.python-version b/apps/slackbot/.python-version new file mode 100644 index 00000000..8531a3b7 --- /dev/null +++ b/apps/slackbot/.python-version @@ -0,0 +1 @@ +3.12.2 diff --git a/apps/slackbot/.vscode/launch.json b/apps/slackbot/.vscode/launch.json new file mode 100644 index 00000000..6e37c6ef --- /dev/null +++ b/apps/slackbot/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to debugpy (F3 bot)", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "justMyCode": false + } + ] +} diff --git a/apps/slackbot/CHANGELOG.md b/apps/slackbot/CHANGELOG.md new file mode 100644 index 00000000..1f4fb519 --- /dev/null +++ b/apps/slackbot/CHANGELOG.md @@ -0,0 +1,317 @@ +# CHANGELOG + + + +## v1.13.0 (2026-06-06) + +### Bug Fixes + +- Making both inputs required on adding event tag + ([`27d70da`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/27d70da96967e39a26e57be13fd9ea851a10e51f)) + +### Features + +- Migrated SLT/positions to utilize the API + ([`361b82e`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/361b82e9d29c43d48c779ee6ae60ef0ee3531054)) + +### Refactoring + +- Added access control header to root path to better reflect status + ([`be75f6a`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/be75f6a3d748c9cea86864a973959d8c05503495)) + + +## v1.12.0 (2026-06-01) + +### Features + +- Added context to DR posts so DR users (with possible incorrect home region ids) can be identified + ([`4126820`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/4126820d3384b7e88febe362aff1576f3cfb8b2c)) + + +## v1.11.2 (2026-05-21) + +### Bug Fixes + +- Dropping a COQ from the preblast now actually removes them + ([`0cfcd77`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/0cfcd77972ffff605c2be64540bba747475ce71c)) + +- Fixed backblast reminders only being sent for closed events + ([`db813bc`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/db813bcd06c3318f4e079610dbb08ab7df62db4c)) + + +## v1.11.1 (2026-05-20) + +### Bug Fixes + +- Hotfix for event instance and series forms only showing custom event types and tags + ([`ec675ac`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/ec675ac58f535a625faaa80a7fbc4c16d045db01)) + + +## v1.11.0 (2026-05-20) + +### Bug Fixes + +- Cleaned up bot logging messages a bit + ([`5316f87`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/5316f873fe2611ac3bd857b771e4ba25a54bae5d)) + +- Fixed error on closing events + ([`e7ea76a`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/e7ea76a1caf4207fad1a87d20b6c722e349d4436)) + +- Help response now has stats link that points to the correct user_id + ([`ccc667d`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/ccc667da179ab4442297b10383ad4c75748e89d4)) + +### Features + +- Added bot logging to Slack ([#211](https://github.com/F3-Nation/f3-nation-slack-bot/pull/211), + [`bf36107`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/bf361078b31e3a3fc0941866f6d6559a0724c45e)) + +### Refactoring + +- Added application and infrastructure modules to script container build + ([`a9e00b0`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/a9e00b0e6b228f675e34380728021decfa508004)) + +- Beginning migration to our API (starting with event tags admin) and other refactorings + ([`0f30861`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/0f30861ce2edffdf73936ffc2823d582a1d5132c)) + +- Fixing a production query issue on achievements + ([`46a1d0a`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/46a1d0a9a0d62308a3ebd13942980ba11d9a3f56)) + +- Launching achievements in dark release + ([`2c3d1be`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/2c3d1be9b6a0ef60195b995496c67cd15d35b401)) + +- Making manual achievement message more consistent with auto achievement + ([`1d90891`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/1d908919f1d55dcaf790dcf9be2f9b2b929ab08f)) + +- Updated all dependencies and some additional paxminer cleanup + ([`cf58eda`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/cf58eda984dde66b40d7bdb5fd115c7a0aec050c)) + + +## v1.10.3 (2026-05-05) + +### Bug Fixes + +- Fixed users and channels not being tagged in backblast text + ([`d631b8e`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/d631b8e7c1ae0c74a2203f6b415302cf3e659868)) + + +## v1.10.2 (2026-05-02) + + +## v1.10.1 (2026-05-02) + +### Bug Fixes + +- Hotfix for backblast moleskin parsing preventing database save + ([`504f76f`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/504f76fbbf14fcb4fce13bd25067c7249b7ee07b)) + + +## v1.10.0 (2026-05-02) + +### Features + +- Added option to change OPEN color on calendar images (Calendar Settings -> General Calendar + Settings) + ([`73c0689`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/73c0689a2059cebec6ef5a0d26acf9c2736bea98)) + +- Added priority ordering of coloring logic + ([`858b4a7`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/858b4a7c78734ec7ad331d7fb3a3158895f54fb4)) + +### Refactoring + +- Removed handling of map update webhook as the API now has cascading + ([`fa08c76`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/fa08c76c0b1fb39e7923c3a8fe6eb1febbc2ba1f)) + + +## v1.9.0 (2026-04-30) + +### Bug Fixes + +- Fixing user mentions in moleskin of downrange cross posts + ([`392486a`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/392486ae760c7e03fba970cf0c37d537a6e4fa68)) + +- Preventing invalid trigger error when a new position is added, and other SLT menu enhancements + ([`0a6bc35`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/0a6bc35ca05c8bd47a04c03fa5cdee846b86ab62)) + +### Features + +- Added boybands to automated downrange posts + ([`8df4049`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/8df4049c270264eb1f700be3eedbfd99a743ab16)) + +- Added setting for when Q / preblast reminders go out to Qs + ([`c076a6a`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/c076a6a6936d5c972ce4648ad8e29bde8618b660)) + + +## v1.8.0 (2026-04-26) + +### Features + +- Added a missing backblast menu for admins and site Qs, accessible from the New Backblast selection + screen + ([`34a808c`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/34a808c666d867ed3b56a687381b117918723e7b)) + + +## v1.7.0 (2026-04-24) + +### Features + +- Added a new `nearby special events` menu to see upcoming special events in nearby regions + ([`92c6649`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/92c664913584bf7b1ed285cef720ffa89bc48f1e)) + + +## v1.6.0 (2026-04-23) + +### Documentation + +- Added new snarky responses for HC and un-HC events; enhance welcome message templates + ([#197](https://github.com/F3-Nation/f3-nation-slack-bot/pull/197), + [`11a2172`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/11a2172659f047a3f07bda5b6940e0a3fcbb6507)) + +### Features + +- Added option to only announce HCs or unHCs in preblast and preventing thread spamming + ([`bd4e272`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/bd4e2722e4a51194a963abb228c0e943df41de32)) + + +## v1.5.2 (2026-04-22) + +### Bug Fixes + +- Logic on when to run the home region nudge script + ([`3c90e8e`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/3c90e8e1c8d4882639b070ef9f1a38c1ffaca773)) + + +## v1.5.1 (2026-04-22) + +### Bug Fixes + +- Fixed divide by zero error on home region nudge script + ([`ea10c90`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/ea10c90869cfc7640cc8dcdbc40a2845b1d8f47c)) + + +## v1.5.0 (2026-04-21) + +### Features + +- Added script for nudging users about their home region when it looks like they joined a new one + ([`cb2f798`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/cb2f798defc1017f4c90ab28568d67a84f855ecd)) + + +## v1.4.2 (2026-04-21) + +### Bug Fixes + +- Fixed downrange region selector not working for non-admin + ([`8d53263`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/8d53263423686e4765b5c2e95d43db531672aae9)) + + +## v1.4.1 (2026-04-20) + +### Bug Fixes + +- Added direct email method and surfacing region contact info on downrange search + ([`b411e93`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/b411e93e597c6787162e57ce041c5312f2113bbe)) + +- Fixed user mention and other formatting for downrange posts + ([`e9dc680`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/e9dc68080bab2b54f70d6064d11c83d923a99ea9)) + + +## v1.4.0 (2026-04-20) + +### Bug Fixes + +- Fixed query which was preventing preblast reminders from generating + ([`0ed3404`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/0ed3404286ddf5f974b59d12ad5ce9bf8d4d52e6)) + +### Features + +- Added a downrange feature for automated DR posts and requesting Slack space invites + ([`a71008d`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/a71008d125ee9af8767e2ff2b15540d98360e16d)) + + +## v1.3.0 (2026-04-18) + +### Features + +- Feat: added HC / unHC thread callout functionality + ([`3b36a60`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/3b36a60a38b309773845cf5d98a875d90b5c13e0)) + + +## v1.2.0 (2026-04-18) + +### Features + +- Added optional location to unscheduled backblast form + ([`18bd74f`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/18bd74f8702523dbddcc2c17f48ee64bf2b7ab62)) + + +## v1.1.1 (2026-04-15) + +### Bug Fixes + +- More cleanup + ([`61e8bbe`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/61e8bbef6c31ac4b102a5b1a4da52977321f35f4)) + +- Removed leftover PaxMiner migration bits + ([#192](https://github.com/F3-Nation/f3-nation-slack-bot/pull/192), + [`f1d283a`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/f1d283a53d065f6f710f26637c33f63478590d31)) + +### Refactoring + +- Removed all legacy paxminer functionality + ([`61e8bbe`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/61e8bbef6c31ac4b102a5b1a4da52977321f35f4)) + + +## v1.1.0 (2026-04-11) + +### Bug Fixes + +- Improved user search to sort by relevance and optionally include home region + ([`adba213`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/adba2139c3a7cb818e863631685d88d7fd1431f7)) + +### Features + +- Added new fields to user form: who brought you, f3 name origin story, and my f3why + ([`b809f50`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/b809f50d50e0ec42c0fff2e95a8a2caf468c8abb)) + + +## v1.0.3 (2026-04-10) + +### Bug Fixes + +- Fixed calendar list showing all regions' locations when group by location used + ([#189](https://github.com/F3-Nation/f3-nation-slack-bot/pull/189), + [`f8b69dc`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/f8b69dcc26520e93f5c863a5fa11198640cf418a)) + + +## v1.0.2 (2026-04-08) + +### Bug Fixes + +- Added a better backblast error message for channel not found + ([`fbc789f`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/fbc789faf298bbe62170e277a420b2b40827a27c)) + +- Addressed common IntegrityErrors from backblasts and HC calendar events + ([`210e180`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/210e1806b9e3fc1489730f17d0d2d66cf20945fd)) + +- Fixed preblast send default behavior to 'send now' for day before event + ([`12ccbb6`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/12ccbb657a70efa4c61ae4958dc90ca8eb318f35)) + +- Preventing re-deployment on staging when automerging back to main + ([`f852377`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/f85237785a3214059fed6f234ff82a9264a6e7dc)) + + +## v1.0.1 (2026-04-08) + +### Bug Fixes + +- Added CHANGELOG.md and version automation + ([#188](https://github.com/F3-Nation/f3-nation-slack-bot/pull/188), + [`7fa0b75`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/7fa0b756e23de1ba1371fb8e673aac8d31b4ba17)) + +- Removed poetry build step from release automation + ([`fdddfcb`](https://github.com/F3-Nation/f3-nation-slack-bot/commit/fdddfcb94108a1789860379f7e6da00823e4d536)) + + +## v1.0.0 (2026-04-08) + +- Initial Release diff --git a/apps/slackbot/Dockerfile b/apps/slackbot/Dockerfile new file mode 100644 index 00000000..ba8a4a49 --- /dev/null +++ b/apps/slackbot/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 APP_HOME=/app GOOGLE_FUNCTION_TARGET=handler +WORKDIR $APP_HOME + +COPY . ./ +RUN pip install --no-cache-dir . +EXPOSE 8080 +CMD ["functions-framework", "--target", "handler", "--port", "8080"] \ No newline at end of file diff --git a/apps/slackbot/README.md b/apps/slackbot/README.md new file mode 100644 index 00000000..46803437 --- /dev/null +++ b/apps/slackbot/README.md @@ -0,0 +1,81 @@ +# F3 Nation Slack Bot + +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) + +F3 Nation Slack Bot runs inside the monorepo and now follows the same local workflow as the other apps. + +## Local development (monorepo) + +### Prerequisites + +1. Docker running locally +2. Node and pnpm installed for the monorepo +3. `uv` available for Python dependency/runtime management + +### One-time setup + +From the repo root: + +```bash +pnpm local:setup +``` + +This creates `apps/slackbot/.env` from `apps/slackbot/.env.local.example`, then starts shared Docker services and runs DB migration/seed steps. + +### Configure Slack credentials + +Edit `apps/slackbot/.env` and set at minimum: + +- `SLACK_SIGNING_SECRET` +- `SLACK_BOT_TOKEN` +- `SLACK_APP_TOKEN` (required for Socket Mode) + +### Start all local apps + +From the repo root: + +```bash +pnpm dev +``` + +Slackbot starts automatically with the rest of the workspace apps. + +- Slackbot local URL: http://localhost:3006 +- Connection mode: Socket Mode only (no localtunnel) + +## Slack app manifest workflow + +At startup, `app_startup.sh` regenerates `app_manifest.json` from `app_manifest.template.json`. + +1. Start dev (`pnpm dev`) so `app_manifest.json` is generated. +2. In Slack app settings, open **App Manifest**. +3. Replace the manifest with `apps/slackbot/app_manifest.json`. +4. Save and reinstall if prompted. + +The generated manifest enables Socket Mode and removes slash command URLs that are only needed for tunnel-based HTTP event delivery. + +## Step debugging + +1. Set `ENABLE_DEBUGGING=true` in `apps/slackbot/.env`. +2. Start dev with `pnpm dev`. +3. Attach VS Code debugger to `debugpy` on port `5678`. + +## Scripts + +Run from `apps/slackbot`: + +```bash +pnpm dev +pnpm test +pnpm lint +``` + +## Codebase notes + +- `main.py`: app entrypoint and Slack event handling +- `utilities/routing.py`: request/action routing map +- `utilities/slack/actions.py`: shared action/callback constants +- `features/`: feature handlers and UI building logic +- `utilities/slack/orm.py`: legacy custom Slack UI helper layer + +Data access currently uses SQLAlchemy/f3-data-models (`packages/db-python`) while API migration work continues. diff --git a/apps/slackbot/__init__.py b/apps/slackbot/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/slackbot/app_manifest.template.json b/apps/slackbot/app_manifest.template.json new file mode 100644 index 00000000..406cd92e --- /dev/null +++ b/apps/slackbot/app_manifest.template.json @@ -0,0 +1,155 @@ +{ + "display_information": { + "name": "f3-nation-local", + "description": "The official F3 Slack app to manage your region's scheduling, signups, attendance tracking, and more!", + "background_color": "#000000" + }, + "features": { + "bot_user": { + "display_name": "f3-Nation-Local", + "always_online": true + }, + "shortcuts": [ + { + "name": "Create a backblast", + "type": "global", + "callback_id": "backblast_shortcut", + "description": "Opens a form to create a backblast for a recent event" + }, + { + "name": "Create a preblast", + "type": "global", + "callback_id": "preblast_shortcut", + "description": "Opens a form to create a preblast for an upcoming event" + }, + { + "name": "Open F3 Nation Settings", + "type": "global", + "callback_id": "settings_shortcut", + "description": "Opens F3 user and region settings (if you're an admin)" + }, + { + "name": "Open F3 Calendar", + "type": "global", + "callback_id": "calendar_shortcut", + "description": "Opens your F3 region's calendar" + }, + { + "name": "Tag an Achievement", + "type": "global", + "callback_id": "tag_achievement_shortcut", + "description": "Tag an achievement manually for someone" + } + ], + "slash_commands": [ + { + "command": "/preblast", + "url": "https://HOST-PLACEHOLDER/slack/events", + "description": "Launch preblast form", + "should_escape": false + }, + { + "command": "/f3-nation-settings", + "url": "https://HOST-PLACEHOLDER/slack/events", + "description": "Managers your region's settings for F3 Nation, including your schedule", + "should_escape": false + }, + { + "command": "/backblast", + "url": "https://HOST-PLACEHOLDER/slack/events", + "description": "Launch backblast form", + "should_escape": false + }, + { + "command": "/tag-achievement", + "url": "https://HOST-PLACEHOLDER/slack/events", + "description": "Lauches a form for manually tagging achievements", + "should_escape": false + }, + { + "command": "/f3-calendar", + "url": "https://HOST-PLACEHOLDER/slack/events", + "description": "Opens the event calendar", + "should_escape": false + }, + { + "command": "/help", + "url": "https://HOST-PLACEHOLDER/slack/events", + "description": "Opens a help menu", + "should_escape": false + } + ] + }, + "oauth_config": { + "redirect_urls": ["https://HOST-PLACEHOLDER/slack/install"], + "scopes": { + "user": ["files:write"], + "bot": [ + "app_mentions:read", + "canvases:read", + "canvases:write", + "channels:history", + "channels:join", + "channels:read", + "chat:write", + "chat:write.customize", + "chat:write.public", + "commands", + "files:read", + "files:write", + "im:history", + "im:read", + "im:write", + "incoming-webhook", + "reactions:read", + "reactions:write", + "team:read", + "users.profile:read", + "users:read", + "users:read.email", + "channels:manage", + "channels:write.invites", + "channels:write.topic", + "emoji:read", + "groups:history", + "groups:read", + "groups:write", + "groups:write.topic", + "groups:write.invites", + "im:write.topic", + "metadata.message:read", + "links.embed:write", + "links:read", + "links:write", + "mpim:history", + "mpim:read", + "mpim:write", + "reminders:read", + "pins:read", + "pins:write", + "remote_files:read", + "reminders:write", + "remote_files:share", + "mpim:write.topic", + "remote_files:write", + "usergroups:read", + "usergroups:write", + "users:write" + ] + } + }, + "settings": { + "event_subscriptions": { + "request_url": "https://HOST-PLACEHOLDER/slack/events", + "bot_events": ["app_mention", "team_join"] + }, + "interactivity": { + "is_enabled": true, + "request_url": "https://HOST-PLACEHOLDER/slack/events", + "message_menu_options_url": "https://HOST-PLACEHOLDER/slack/events" + }, + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": false + } +} diff --git a/apps/slackbot/app_startup.sh b/apps/slackbot/app_startup.sh new file mode 100755 index 00000000..81397fed --- /dev/null +++ b/apps/slackbot/app_startup.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# filepath: /app/app_startup.sh + +set -Eeu -o pipefail + +# Simple supervisor that keeps the local server running in Socket Mode. +APP_PID="" +STOPPING=false +TEMPLATE_FILE="app_manifest.template.json" +ENV_FILE=".env" +SOCKET_MODE_VAR="SOCKET_MODE" +LOCAL_HTTP_PORT_VAR="LOCAL_HTTP_PORT" + +# Preflight checks for required CLIs +assert_cmd() { command -v "$1" >/dev/null 2>&1 || { echo "Required command not found: $1"; exit 1; }; } +assert_cmd watchfiles +assert_cmd dotenv + +ensure_socket_mode() { + local existing="" + if [[ -f "${ENV_FILE}" ]] && grep -E "^${SOCKET_MODE_VAR}=" "${ENV_FILE}" >/dev/null; then + existing=$(grep -E "^${SOCKET_MODE_VAR}=" "${ENV_FILE}" | tail -1 | cut -d= -f2-) + fi + if [[ -z "${existing}" ]]; then + existing="true" + # Add a newline if .env exists and does not end with one + if [[ -f "${ENV_FILE}" ]] && [[ $(tail -c1 "${ENV_FILE}") != "" ]]; then + echo >> "${ENV_FILE}" + fi + echo "${SOCKET_MODE_VAR}=${existing}" >> "${ENV_FILE}" + echo "Initialized ${SOCKET_MODE_VAR}=${existing} in ${ENV_FILE}" + fi + + if [[ "${existing}" != "true" ]]; then + sed -i "s/^${SOCKET_MODE_VAR}=.*/${SOCKET_MODE_VAR}=true/" "${ENV_FILE}" + existing="true" + echo "Forced ${SOCKET_MODE_VAR}=true in ${ENV_FILE}" + fi + + SOCKET_MODE="${existing}" + export SOCKET_MODE +} + +ensure_local_http_port() { + local existing="" + if [[ -f "${ENV_FILE}" ]] && grep -E "^${LOCAL_HTTP_PORT_VAR}=" "${ENV_FILE}" >/dev/null; then + existing=$(grep -E "^${LOCAL_HTTP_PORT_VAR}=" "${ENV_FILE}" | tail -1 | cut -d= -f2-) + fi + if [[ -z "${existing}" ]]; then + existing="3006" + if [[ -f "${ENV_FILE}" ]] && [[ $(tail -c1 "${ENV_FILE}") != "" ]]; then + echo >> "${ENV_FILE}" + fi + echo "${LOCAL_HTTP_PORT_VAR}=${existing}" >> "${ENV_FILE}" + echo "Initialized ${LOCAL_HTTP_PORT_VAR}=${existing} in ${ENV_FILE}" + fi +} + +generate_manifest() { + local host="$1" + echo "Generating app_manifest.json for host: ${host}" + if [[ ! -f "${TEMPLATE_FILE}" ]]; then + echo "Template file not found: ${TEMPLATE_FILE}" + exit 1 + fi + # First apply HOST-PLACEHOLDER substitution + sed "s|HOST-PLACEHOLDER|${host}|g" "${TEMPLATE_FILE}" > app_manifest.json + + tmp_file="app_manifest.tmp.json" + sed '/"url": "https:\/\//d' app_manifest.json > "${tmp_file}" + sed 's/"socket_mode_enabled": false/"socket_mode_enabled": true/' "${tmp_file}" > app_manifest.json + rm -f "${tmp_file}" + echo "Generated app_manifest.json for SOCKET_MODE (no slash command URLs, socket_mode_enabled=true)" +} + +start_app() { + echo "Starting the app with watchfiles..." + ( watchfiles --filter python 'dotenv -f .env run -- python main.py' ) & + APP_PID=$! + echo "App PID: ${APP_PID}" +} + +cleanup() { + echo "Shutting down processes..." + if [[ -n "${APP_PID:-}" ]] && kill -0 "${APP_PID}" 2>/dev/null; then + kill "${APP_PID}" 2>/dev/null || true + fi + wait 2>/dev/null || true +} +# On SIGINT/SIGTERM, mark stopping, cleanup, and exit +trap 'STOPPING=true; cleanup; exit 0' SIGINT SIGTERM +# Always cleanup on script exit as a safety net +trap cleanup EXIT + +ensure_socket_mode +ensure_local_http_port + +if [[ "${SOCKET_MODE}" != "true" ]]; then + echo "SOCKET_MODE must be true for local development." + exit 1 +fi + +echo "SOCKET_MODE=true; localtunnel is disabled." +generate_manifest "localhost:3006" + +while true; do + start_app + if ! wait -n "${APP_PID}"; then + : + fi + + if [[ "$STOPPING" == true ]]; then + break + fi + + if ! kill -0 "${APP_PID}" 2>/dev/null; then + echo "App process exited; restarting..." + fi +done \ No newline at end of file diff --git a/apps/slackbot/application/__init__.py b/apps/slackbot/application/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/slackbot/application/ao/__init__.py b/apps/slackbot/application/ao/__init__.py new file mode 100644 index 00000000..1d2ef617 --- /dev/null +++ b/apps/slackbot/application/ao/__init__.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class AoData(BaseModel): + id: int + name: str + parent_id: int | None = None + org_type: str = "ao" + description: str | None = None + is_active: bool = True + default_location_id: int | None = None + logo_url: str | None = None + meta: dict | None = None diff --git a/apps/slackbot/application/ao/repository.py b/apps/slackbot/application/ao/repository.py new file mode 100644 index 00000000..0eb0a962 --- /dev/null +++ b/apps/slackbot/application/ao/repository.py @@ -0,0 +1,48 @@ +from typing import Protocol + +from application.ao import AoData + + +class AoRepository(Protocol): + """ + Defines the data-access contract for AOs (workout locations / orgs of type "ao"). + + Concrete implementations may be backed by the F3 Nation API, the legacy + SQLAlchemy DbManager, or a test double. + """ + + def get_by_parent_org(self, parent_org_id: int) -> list[AoData]: + """Return active AOs whose parent org is *parent_org_id*.""" + ... + + def get_by_id(self, ao_id: int) -> AoData | None: + """Return a single AO by primary key, or None if not found.""" + ... + + def create( + self, + parent_id: int, + name: str, + description: str | None, + slack_channel_id: str | None, + default_location_id: int | None, + ) -> AoData: + """Create a new AO and return the created record.""" + ... + + def update( + self, + ao_id: int, + parent_id: int, + name: str, + description: str | None, + slack_channel_id: str | None, + default_location_id: int | None, + logo_url: str | None = None, + ) -> None: + """Update an existing AO (crupdate POST).""" + ... + + def delete(self, ao_id: int) -> None: + """Soft-delete an AO (cascades to associated events and instances).""" + ... diff --git a/apps/slackbot/application/ao/service.py b/apps/slackbot/application/ao/service.py new file mode 100644 index 00000000..047476a8 --- /dev/null +++ b/apps/slackbot/application/ao/service.py @@ -0,0 +1,65 @@ +from application.ao import AoData +from application.ao.repository import AoRepository + + +class AoService: + """ + Business logic for AOs (workout orgs of type "ao"). + + Data access is delegated to an ``AoRepository`` injected by the caller + (composition root), keeping the application layer independent of + infrastructure details. + """ + + def __init__(self, repository: AoRepository) -> None: + self._repository: AoRepository = repository + + def get_region_aos(self, parent_org_id: int | str) -> list[AoData]: + """Return active AOs for the given parent org (region).""" + return self._repository.get_by_parent_org(int(parent_org_id)) + + def get_ao_by_id(self, ao_id: int) -> AoData | None: + """Return a single AO by ID, or *None* if not found.""" + return self._repository.get_by_id(ao_id) + + def create_ao( + self, + parent_id: int | str, + name: str, + description: str | None, + slack_channel_id: str | None, + default_location_id: int | str | None, + ) -> AoData: + """Create a new AO and return the created record.""" + return self._repository.create( + parent_id=int(parent_id), + name=name, + description=description, + slack_channel_id=slack_channel_id, + default_location_id=int(default_location_id) if default_location_id is not None else None, + ) + + def update_ao( + self, + ao_id: int, + parent_id: int | str, + name: str, + description: str | None, + slack_channel_id: str | None, + default_location_id: int | str | None, + logo_url: str | None = None, + ) -> None: + """Update an existing AO.""" + self._repository.update( + ao_id=ao_id, + parent_id=int(parent_id), + name=name, + description=description, + slack_channel_id=slack_channel_id, + default_location_id=int(default_location_id) if default_location_id is not None else None, + logo_url=logo_url, + ) + + def delete_ao(self, ao_id: int) -> None: + """Soft-delete an AO and cascade to associated events/instances.""" + self._repository.delete(ao_id) diff --git a/apps/slackbot/application/event_instance/__init__.py b/apps/slackbot/application/event_instance/__init__.py new file mode 100644 index 00000000..d22855cc --- /dev/null +++ b/apps/slackbot/application/event_instance/__init__.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from datetime import date +from typing import Any + +from pydantic import BaseModel + + +class EventInstanceData(BaseModel): + id: int + name: str | None = None + description: str | None = None + org_id: int # AO org + location_id: int | None = None + event_type_ids: list[int] = [] + event_tag_ids: list[int] = [] + start_date: date | None = None + start_time: str | None = None # "HHMM" format + end_time: str | None = None # "HHMM" format + is_active: bool = True + is_private: bool = False + meta: dict | None = None + highlight: bool = False + preblast_rich: Any | None = None + preblast: str | None = None + series_exception: str | None = None # "closed" | "different-time" | "miscellaneous" | None diff --git a/apps/slackbot/application/event_instance/repository.py b/apps/slackbot/application/event_instance/repository.py new file mode 100644 index 00000000..6340a554 --- /dev/null +++ b/apps/slackbot/application/event_instance/repository.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from datetime import date +from typing import Any, Protocol + +from application.event_instance import EventInstanceData + + +class EventInstanceRepository(Protocol): + """ + Defines the data-access contract for event instances. + + Concrete implementations may be backed by the F3 Nation API, the legacy + SQLAlchemy DbManager, or a test double. + """ + + def get_list( + self, + region_org_id: int, + start_date: date, + ao_org_id: int | None = None, + ) -> list[EventInstanceData]: + """Return active instances on or after *start_date* for the region (or specific AO).""" + ... + + def get_by_id(self, instance_id: int) -> EventInstanceData | None: + """Return a single event instance by primary key, or None if not found.""" + ... + + def create( + self, + name: str, + org_id: int, + start_date: date, + start_time: str, + end_time: str, + description: str | None, + location_id: int | None, + event_type_ids: list[int], + event_tag_ids: list[int], + is_active: bool, + is_private: bool, + meta: dict | None, + highlight: bool, + preblast_rich: Any | None, + preblast: str | None, + ) -> EventInstanceData: + """Create a new event instance and return the created record.""" + ... + + def update( + self, + instance_id: int, + name: str, + org_id: int, + start_date: date, + start_time: str, + end_time: str, + description: str | None, + location_id: int | None, + event_type_ids: list[int], + event_tag_ids: list[int], + is_active: bool, + is_private: bool, + meta: dict | None, + highlight: bool, + preblast_rich: Any | None, + preblast: str | None, + ) -> EventInstanceData: + """Update an existing event instance and return the updated record.""" + ... + + def close(self, instance: EventInstanceData, meta: dict) -> None: + """Mark an instance as closed (seriesException="closed") with the given meta.""" + ... + + def reopen(self, instance: EventInstanceData) -> None: + """Clear the seriesException field on an instance.""" + ... + + def delete(self, instance_id: int) -> None: + """Hard-delete an event instance.""" + ... diff --git a/apps/slackbot/application/event_instance/service.py b/apps/slackbot/application/event_instance/service.py new file mode 100644 index 00000000..288497c7 --- /dev/null +++ b/apps/slackbot/application/event_instance/service.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from datetime import date +from typing import Any + +from application.event_instance import EventInstanceData +from application.event_instance.repository import EventInstanceRepository + + +class EventInstanceService: + """ + Business logic for event instances. + + Data access is delegated to an ``EventInstanceRepository`` injected by the + caller (composition root), keeping the application layer independent of + infrastructure details. + """ + + def __init__(self, repository: EventInstanceRepository) -> None: + self._repository: EventInstanceRepository = repository + + def get_region_instances( + self, + region_org_id: int | str, + start_date: date, + ao_org_id: int | str | None = None, + limit: int = 40, + ) -> list[EventInstanceData]: + """Return upcoming active instances for a region, optionally filtered by AO.""" + records = self._repository.get_list( + region_org_id=int(region_org_id), + start_date=start_date, + ao_org_id=int(ao_org_id) if ao_org_id is not None else None, + ) + # Sort by date, time, name then cap at limit + records.sort(key=lambda x: (x.start_date or date.min, x.start_time or "", x.name or "")) + return records[:limit] + + def get_by_id(self, instance_id: int) -> EventInstanceData | None: + """Return a single event instance, or *None* if not found.""" + return self._repository.get_by_id(instance_id) + + def create_instance( + self, + name: str, + org_id: int | str, + start_date: date, + start_time: str, + end_time: str, + description: str | None = None, + location_id: int | str | None = None, + event_type_ids: list[int] | None = None, + event_tag_ids: list[int] | None = None, + is_active: bool = True, + is_private: bool = False, + meta: dict | None = None, + highlight: bool = False, + preblast_rich: Any | None = None, + preblast: str | None = None, + ) -> EventInstanceData: + """Create a new event instance and return the created record.""" + return self._repository.create( + name=name, + org_id=int(org_id), + start_date=start_date, + start_time=start_time, + end_time=end_time, + description=description, + location_id=int(location_id) if location_id is not None else None, + event_type_ids=event_type_ids or [], + event_tag_ids=event_tag_ids or [], + is_active=is_active, + is_private=is_private, + meta=meta, + highlight=highlight, + preblast_rich=preblast_rich, + preblast=preblast, + ) + + def update_instance( + self, + instance_id: int, + name: str, + org_id: int | str, + start_date: date, + start_time: str, + end_time: str, + description: str | None = None, + location_id: int | str | None = None, + event_type_ids: list[int] | None = None, + event_tag_ids: list[int] | None = None, + is_active: bool = True, + is_private: bool = False, + meta: dict | None = None, + highlight: bool = False, + preblast_rich: Any | None = None, + preblast: str | None = None, + ) -> EventInstanceData: + """Update an existing event instance and return the updated record.""" + return self._repository.update( + instance_id=instance_id, + name=name, + org_id=int(org_id), + start_date=start_date, + start_time=start_time, + end_time=end_time, + description=description, + location_id=int(location_id) if location_id is not None else None, + event_type_ids=event_type_ids or [], + event_tag_ids=event_tag_ids or [], + is_active=is_active, + is_private=is_private, + meta=meta, + highlight=highlight, + preblast_rich=preblast_rich, + preblast=preblast, + ) + + def _get_existing_instance_for_state_change(self, instance_id: int) -> EventInstanceData: + """Return an existing instance for close/reopen operations.""" + existing = self._repository.get_by_id(instance_id) + if existing is None: + raise ValueError(f"Event instance {instance_id} was not found") + return existing + + def close_instance(self, instance_id: int, close_reason: str | None) -> None: + """Close an event instance with an optional reason stored in meta.""" + existing = self._get_existing_instance_for_state_change(instance_id) + meta = dict(existing.meta or {}) + if close_reason: + meta["series_exception_reason"] = close_reason + self._repository.close(instance=existing, meta=meta) + + def reopen_instance(self, instance_id: int) -> None: + """Remove the closed status from an event instance.""" + existing = self._get_existing_instance_for_state_change(instance_id) + self._repository.reopen(instance=existing) + + def delete_instance(self, instance_id: int) -> None: + """Hard-delete an event instance.""" + self._repository.delete(instance_id) diff --git a/apps/slackbot/application/event_tag/__init__.py b/apps/slackbot/application/event_tag/__init__.py new file mode 100644 index 00000000..87c42336 --- /dev/null +++ b/apps/slackbot/application/event_tag/__init__.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class EventTagData(BaseModel): + id: int + name: str + color: str | None + specific_org_id: int | None + is_active: bool = True + description: str | None = None diff --git a/apps/slackbot/application/event_tag/repository.py b/apps/slackbot/application/event_tag/repository.py new file mode 100644 index 00000000..09c405c7 --- /dev/null +++ b/apps/slackbot/application/event_tag/repository.py @@ -0,0 +1,36 @@ +from typing import Protocol + +from application.event_tag import EventTagData + + +class EventTagRepository(Protocol): + """ + Defines the data-access contract for event tags. + + Concrete implementations may be backed by the F3 Nation API, the legacy + SQLAlchemy DbManager, or a test double. + """ + + def get_by_org(self, org_id: int) -> list[EventTagData]: + """Return only org-specific (custom) event tags for the given org.""" + ... + + def get_all_for_org(self, org_id: int) -> list[EventTagData]: + """Return org-specific and global (nation-wide) event tags visible to the given org.""" + ... + + def get_by_id(self, tag_id: int) -> EventTagData | None: + """Return a single event tag by primary key, or None if not found.""" + ... + + def create(self, name: str, color: str, org_id: int) -> None: + """Create a new org-specific event tag.""" + ... + + def update(self, tag_id: int, name: str, color: str) -> None: + """Update the name and colour of an existing event tag.""" + ... + + def delete(self, tag_id: int) -> None: + """Soft-delete an event tag.""" + ... diff --git a/apps/slackbot/application/event_tag/service.py b/apps/slackbot/application/event_tag/service.py new file mode 100644 index 00000000..c2e07a7c --- /dev/null +++ b/apps/slackbot/application/event_tag/service.py @@ -0,0 +1,39 @@ +from application.event_tag import EventTagData +from application.event_tag.repository import EventTagRepository + + +class EventTagService: + """ + Business logic for event tags. + + Data access is delegated to an ``EventTagRepository`` and is injected by the + caller (composition root), which keeps the application layer independent of + infrastructure details. + """ + + def __init__(self, repository: EventTagRepository) -> None: + self._repository: EventTagRepository = repository + + def get_org_event_tags(self, org_id: int | str) -> list[EventTagData]: + """Return org-specific event tags for *org_id*.""" + return self._repository.get_by_org(int(org_id)) + + def get_all_tags_for_org(self, org_id: int | str) -> list[EventTagData]: + """Return org-specific and global (nation-wide) event tags visible to *org_id*.""" + return self._repository.get_all_for_org(int(org_id)) + + def get_event_tag_by_id(self, tag_id: int) -> EventTagData | None: + """Return a single event tag, or *None* if not found.""" + return self._repository.get_by_id(tag_id) + + def create_org_specific_tag(self, name: str, color: str, org_id: int | str) -> None: + """Create a new org-specific event tag.""" + self._repository.create(name, color, int(org_id)) + + def update_org_specific_tag(self, tag_id: int, name: str, color: str) -> None: + """Update the name and colour of an existing event tag.""" + self._repository.update(tag_id, name, color) + + def delete_org_specific_tag(self, tag_id: int) -> None: + """Soft-delete an event tag.""" + self._repository.delete(tag_id) diff --git a/apps/slackbot/application/event_type/__init__.py b/apps/slackbot/application/event_type/__init__.py new file mode 100644 index 00000000..60c73c0f --- /dev/null +++ b/apps/slackbot/application/event_type/__init__.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class EventTypeData(BaseModel): + id: int + name: str + acronym: str | None = None + event_category: str | None = None # "first_f" | "second_f" | "third_f" + specific_org_id: int | None = None + is_active: bool = True diff --git a/apps/slackbot/application/event_type/repository.py b/apps/slackbot/application/event_type/repository.py new file mode 100644 index 00000000..10b4343c --- /dev/null +++ b/apps/slackbot/application/event_type/repository.py @@ -0,0 +1,36 @@ +from typing import Protocol + +from application.event_type import EventTypeData + + +class EventTypeRepository(Protocol): + """ + Defines the data-access contract for event types. + + Concrete implementations may be backed by the F3 Nation API, the legacy + SQLAlchemy DbManager, or a test double. + """ + + def get_by_org(self, org_id: int) -> list[EventTypeData]: + """Return only org-specific event types for the given org.""" + ... + + def get_all_for_org(self, org_id: int) -> list[EventTypeData]: + """Return org-specific and global (nation-wide) event types visible to the given org.""" + ... + + def get_by_id(self, event_type_id: int) -> EventTypeData | None: + """Return a single event type by primary key, or None if not found.""" + ... + + def create(self, name: str, acronym: str, event_category: str, org_id: int) -> None: + """Create a new org-specific event type.""" + ... + + def update(self, event_type_id: int, name: str, acronym: str, event_category: str) -> None: + """Update the name, acronym, and category of an existing event type.""" + ... + + def delete(self, event_type_id: int) -> None: + """Soft-delete an event type.""" + ... diff --git a/apps/slackbot/application/event_type/service.py b/apps/slackbot/application/event_type/service.py new file mode 100644 index 00000000..f5ddf93f --- /dev/null +++ b/apps/slackbot/application/event_type/service.py @@ -0,0 +1,39 @@ +from application.event_type import EventTypeData +from application.event_type.repository import EventTypeRepository + + +class EventTypeService: + """ + Business logic for event types. + + Data access is delegated to an ``EventTypeRepository`` injected by the + caller (composition root), keeping the application layer independent of + infrastructure details. + """ + + def __init__(self, repository: EventTypeRepository) -> None: + self._repository: EventTypeRepository = repository + + def get_org_specific_event_types(self, org_id: int | str) -> list[EventTypeData]: + """Return only org-specific event types for *org_id*.""" + return self._repository.get_by_org(int(org_id)) + + def get_all_event_types_for_org(self, org_id: int | str) -> list[EventTypeData]: + """Return org-specific and global event types visible to *org_id*.""" + return self._repository.get_all_for_org(int(org_id)) + + def get_event_type_by_id(self, event_type_id: int) -> EventTypeData | None: + """Return a single event type, or *None* if not found.""" + return self._repository.get_by_id(event_type_id) + + def create_org_specific_type(self, name: str, acronym: str, event_category: str, org_id: int | str) -> None: + """Create a new org-specific event type.""" + self._repository.create(name, acronym, event_category, int(org_id)) + + def update_org_specific_type(self, event_type_id: int, name: str, acronym: str, event_category: str) -> None: + """Update the name, acronym, and category of an existing event type.""" + self._repository.update(event_type_id, name, acronym, event_category) + + def delete_org_specific_type(self, event_type_id: int) -> None: + """Soft-delete an event type.""" + self._repository.delete(event_type_id) diff --git a/apps/slackbot/application/location/__init__.py b/apps/slackbot/application/location/__init__.py new file mode 100644 index 00000000..ff05f35e --- /dev/null +++ b/apps/slackbot/application/location/__init__.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + + +class LocationData(BaseModel): + id: int + name: str + description: str | None = None + latitude: float | None = None + longitude: float | None = None + address_street: str | None = None + address_street2: str | None = None + address_city: str | None = None + address_state: str | None = None + address_zip: str | None = None + address_country: str | None = None + is_active: bool = True + org_id: int | None = None diff --git a/apps/slackbot/application/location/repository.py b/apps/slackbot/application/location/repository.py new file mode 100644 index 00000000..f7a3bb73 --- /dev/null +++ b/apps/slackbot/application/location/repository.py @@ -0,0 +1,60 @@ +from typing import Protocol + +from application.location import LocationData + + +class LocationRepository(Protocol): + """ + Defines the data-access contract for locations. + + Concrete implementations may be backed by the F3 Nation API, the legacy + SQLAlchemy DbManager, or a test double. + """ + + def get_by_org(self, org_id: int) -> list[LocationData]: + """Return active locations belonging to *org_id*.""" + ... + + def get_by_id(self, location_id: int) -> LocationData | None: + """Return a single location by primary key, or None if not found.""" + ... + + def create( + self, + name: str, + org_id: int, + description: str | None, + latitude: float | None, + longitude: float | None, + address_street: str | None, + address_street2: str | None, + address_city: str | None, + address_state: str | None, + address_zip: str | None, + address_country: str | None, + ) -> LocationData: + """Create a new location and return it.""" + ... + + def update( + self, + location_id: int, + name: str, + org_id: int, + is_active: bool = True, + description: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + address_street: str | None = None, + address_street2: str | None = None, + address_city: str | None = None, + address_state: str | None = None, + address_zip: str | None = None, + address_country: str | None = None, + ) -> None: + """Update an existing location.""" + ... + + def delete(self, location_id: int) -> None: + """Soft-delete a location.""" + ... diff --git a/apps/slackbot/application/location/service.py b/apps/slackbot/application/location/service.py new file mode 100644 index 00000000..ca4c99ef --- /dev/null +++ b/apps/slackbot/application/location/service.py @@ -0,0 +1,90 @@ +from application.location import LocationData +from application.location.repository import LocationRepository + + +class LocationService: + """ + Business logic for locations. + + Data access is delegated to a ``LocationRepository`` injected by the + caller (composition root), keeping the application layer independent of + infrastructure details. + """ + + def __init__(self, repository: LocationRepository) -> None: + self._repository: LocationRepository = repository + + def get_org_locations(self, org_id: int | str) -> list[LocationData]: + """Return active locations for *org_id*.""" + locations = self._repository.get_by_org(int(org_id)) + return [loc for loc in locations if loc.is_active] + + def get_location_by_id(self, location_id: int) -> LocationData | None: + """Return a single location, or *None* if not found.""" + return self._repository.get_by_id(location_id) + + def create_location( + self, + name: str, + org_id: int | str, + description: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + address_street: str | None = None, + address_street2: str | None = None, + address_city: str | None = None, + address_state: str | None = None, + address_zip: str | None = None, + address_country: str | None = None, + ) -> LocationData: + """Create a new location and return the created record.""" + return self._repository.create( + name=name, + org_id=int(org_id), + description=description, + latitude=latitude, + longitude=longitude, + address_street=address_street, + address_street2=address_street2, + address_city=address_city, + address_state=address_state, + address_zip=address_zip, + address_country=address_country, + ) + + def update_location( + self, + location_id: int, + name: str, + org_id: int, + is_active: bool = True, + description: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + address_street: str | None = None, + address_street2: str | None = None, + address_city: str | None = None, + address_state: str | None = None, + address_zip: str | None = None, + address_country: str | None = None, + ) -> None: + """Update an existing location.""" + self._repository.update( + location_id=location_id, + name=name, + org_id=int(org_id), + is_active=is_active, + description=description, + latitude=latitude, + longitude=longitude, + address_street=address_street, + address_street2=address_street2, + address_city=address_city, + address_state=address_state, + address_zip=address_zip, + address_country=address_country, + ) + + def delete_location(self, location_id: int) -> None: + """Soft-delete a location.""" + self._repository.delete(location_id) diff --git a/apps/slackbot/application/position/__init__.py b/apps/slackbot/application/position/__init__.py new file mode 100644 index 00000000..22072147 --- /dev/null +++ b/apps/slackbot/application/position/__init__.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + + +class PositionData(BaseModel): + id: int + name: str + description: str | None = None + org_id: int | None = None + org_type: str | None = None + is_active: bool = True + + +class UserAssignmentData(BaseModel): + user_id: int + f3_name: str | None = None + + +class PositionWithAssignmentsData(PositionData): + users: list[UserAssignmentData] = [] diff --git a/apps/slackbot/application/position/repository.py b/apps/slackbot/application/position/repository.py new file mode 100644 index 00000000..66d0ec3c --- /dev/null +++ b/apps/slackbot/application/position/repository.py @@ -0,0 +1,47 @@ +from typing import Protocol + +from application.position import PositionData, PositionWithAssignmentsData + + +class PositionRepository(Protocol): + """ + Defines the data-access contract for positions and position assignments. + + Concrete implementations may be backed by the F3 Nation API, the legacy + SQLAlchemy DbManager, or a test double. + """ + + def get_by_org(self, org_id: int) -> list[PositionData]: + """Return org-specific (non-global) positions for the given org.""" + ... + + def get_assignments(self, org_id: int, region_org_id: int) -> list[PositionWithAssignmentsData]: + """Return all positions (with their assigned users) relevant to *org_id*. + + *region_org_id* is always the parent region ID and is used to determine + which position tier (region vs AO) to include. + """ + ... + + def get_by_id(self, position_id: int) -> PositionData | None: + """Return a single position by primary key, or None if not found.""" + ... + + def create(self, name: str, description: str | None, org_id: int, org_type: str) -> PositionData: + """Create a new org-specific position.""" + ... + + def update(self, position_id: int, name: str, description: str | None) -> None: + """Update the name and description of an existing position.""" + ... + + def delete(self, position_id: int) -> None: + """Soft-delete a position by marking it as inactive.""" + ... + + def update_all_assignments(self, org_id: int, assignments: list[dict]) -> None: + """Replace all position assignments for *org_id* with the given list. + + *assignments* is a list of ``{"positionId": int, "userIds": [int, ...]}``. + """ + ... diff --git a/apps/slackbot/application/position/service.py b/apps/slackbot/application/position/service.py new file mode 100644 index 00000000..c6ef3f53 --- /dev/null +++ b/apps/slackbot/application/position/service.py @@ -0,0 +1,48 @@ +from application.position import PositionData, PositionWithAssignmentsData +from application.position.repository import PositionRepository + + +class PositionService: + """ + Business logic for positions and position assignments. + + Data access is delegated to a ``PositionRepository`` injected by the caller + (composition root), which keeps the application layer independent of + infrastructure details. + """ + + def __init__(self, repository: PositionRepository) -> None: + self._repository: PositionRepository = repository + + def get_org_positions(self, org_id: int | str) -> list[PositionData]: + """Return org-specific positions for *org_id*.""" + return self._repository.get_by_org(int(org_id)) + + def get_positions_with_assignments( + self, org_id: int | str, region_org_id: int | str + ) -> list[PositionWithAssignmentsData]: + """Return all positions (with assigned users) relevant to *org_id*.""" + return self._repository.get_assignments(int(org_id), int(region_org_id)) + + def get_by_id(self, position_id: int) -> PositionData | None: + """Return a single position, or *None* if not found.""" + return self._repository.get_by_id(position_id) + + def create_position(self, name: str, description: str | None, org_id: int | str, org_type: str) -> PositionData: + """Create a new org-specific position.""" + return self._repository.create(name, description, int(org_id), org_type) + + def update_position(self, position_id: int, name: str, description: str | None) -> None: + """Update the name and description of an existing position.""" + self._repository.update(position_id, name, description) + + def delete_position(self, position_id: int) -> None: + """Soft-delete a position.""" + self._repository.delete(position_id) + + def update_org_assignments(self, org_id: int | str, assignments: list[dict]) -> None: + """Replace all position assignments for *org_id*. + + *assignments* is a list of ``{"positionId": int, "userIds": [int, ...]}``. + """ + self._repository.update_all_assignments(int(org_id), assignments) diff --git a/apps/slackbot/application/series/__init__.py b/apps/slackbot/application/series/__init__.py new file mode 100644 index 00000000..12ded886 --- /dev/null +++ b/apps/slackbot/application/series/__init__.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class SeriesData(BaseModel): + id: int + name: str = "" + description: str | None = None + is_active: bool = True + is_private: bool = False + highlight: bool = False + location_id: int | None = None + org_id: int = 0 # AO org ID + region_id: int | None = None + start_date: str | None = None # "YYYY-MM-DD" + end_date: str | None = None # "YYYY-MM-DD" + start_time: str | None = None # "HHMM" format + end_time: str | None = None # "HHMM" format + day_of_week: str | None = None # lowercase: "monday" … "sunday" + recurrence_pattern: str | None = None # "weekly" | "monthly" + recurrence_interval: int | None = None + index_within_interval: int | None = None + meta: dict | None = None + event_type_ids: list[int] = [] + # NOTE: the F3 Nation API does not return event_tag_ids for events. + # This field will always be empty when fetched from the API; it is included + # here for forward-compatibility and test doubles. + event_tag_ids: list[int] = [] diff --git a/apps/slackbot/application/series/repository.py b/apps/slackbot/application/series/repository.py new file mode 100644 index 00000000..6ce88ae0 --- /dev/null +++ b/apps/slackbot/application/series/repository.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import Protocol + +from application.series import SeriesData + + +class SeriesRepository(Protocol): + def get_by_region(self, region_id: int, ao_id: int | None = None) -> list[SeriesData]: + """Return active series for a region, optionally filtered to a single AO.""" + ... + + def get_by_id(self, series_id: int) -> SeriesData | None: + """Return a single series, or *None* if not found.""" + ... + + def create( + self, + region_id: int, + ao_id: int, + name: str, + start_date: str, + start_time: str | None, + end_time: str | None, + day_of_week: str, + description: str | None, + location_id: int | None, + end_date: str | None, + recurrence_pattern: str | None, + recurrence_interval: int | None, + index_within_interval: int | None, + event_type_ids: list[int], + event_tag_ids: list[int], + is_active: bool, + is_private: bool, + highlight: bool, + meta: dict | None, + ) -> SeriesData: + """Create a new series. Future instances are generated by the API cascade.""" + ... + + def update( + self, + series_id: int, + region_id: int, + ao_id: int, + name: str, + start_date: str, + start_time: str | None, + end_time: str | None, + description: str | None, + location_id: int | None, + end_date: str | None, + event_type_ids: list[int], + event_tag_ids: list[int], + is_active: bool, + is_private: bool, + highlight: bool, + meta: dict | None, + ) -> SeriesData: + """Update an existing series. Future instances are updated by the API cascade.""" + ... + + def delete(self, series_id: int) -> None: + """Soft-delete a series. Future instances are deleted by the API cascade.""" + ... diff --git a/apps/slackbot/application/series/service.py b/apps/slackbot/application/series/service.py new file mode 100644 index 00000000..f68eff06 --- /dev/null +++ b/apps/slackbot/application/series/service.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from application.series import SeriesData +from application.series.repository import SeriesRepository + + +class SeriesService: + """ + Business logic for event series (workout recurring events). + + Cascade behaviour (instance creation/update/deletion) is handled by the + F3 Nation API, so callers only interact with the series-level record. + """ + + def __init__(self, repository: SeriesRepository) -> None: + self._repository: SeriesRepository = repository + + def get_region_series( + self, + region_id: int | str, + ao_id: int | str | None = None, + ) -> list[SeriesData]: + """Return active series for a region, optionally scoped to one AO.""" + return self._repository.get_by_region( + region_id=int(region_id), + ao_id=int(ao_id) if ao_id is not None else None, + ) + + def get_by_id(self, series_id: int | str) -> SeriesData | None: + """Return a single series, or *None* if not found.""" + return self._repository.get_by_id(int(series_id)) + + def create_series( + self, + region_id: int | str, + ao_id: int | str, + name: str, + start_date: str, + start_time: str | None, + end_time: str | None, + day_of_week: str, + description: str | None = None, + location_id: int | str | None = None, + end_date: str | None = None, + recurrence_pattern: str | None = None, + recurrence_interval: int | None = None, + index_within_interval: int | None = None, + event_type_ids: list[int] | None = None, + event_tag_ids: list[int] | None = None, + is_active: bool = True, + is_private: bool = False, + highlight: bool = False, + meta: dict | None = None, + ) -> SeriesData: + """Create a new series (and trigger API-side instance generation).""" + return self._repository.create( + region_id=int(region_id), + ao_id=int(ao_id), + name=name, + start_date=start_date, + start_time=start_time, + end_time=end_time, + day_of_week=day_of_week, + description=description, + location_id=int(location_id) if location_id is not None else None, + end_date=end_date, + recurrence_pattern=recurrence_pattern, + recurrence_interval=recurrence_interval, + index_within_interval=index_within_interval, + event_type_ids=event_type_ids or [], + event_tag_ids=event_tag_ids or [], + is_active=is_active, + is_private=is_private, + highlight=highlight, + meta=meta, + ) + + def update_series( + self, + series_id: int | str, + region_id: int | str, + ao_id: int | str, + name: str, + start_date: str, + start_time: str | None, + end_time: str | None, + description: str | None = None, + location_id: int | str | None = None, + end_date: str | None = None, + event_type_ids: list[int] | None = None, + event_tag_ids: list[int] | None = None, + is_active: bool = True, + is_private: bool = False, + highlight: bool = False, + meta: dict | None = None, + ) -> SeriesData: + """Update an existing series (and trigger API-side future-instance update).""" + return self._repository.update( + series_id=int(series_id), + region_id=int(region_id), + ao_id=int(ao_id), + name=name, + start_date=start_date, + start_time=start_time, + end_time=end_time, + description=description, + location_id=int(location_id) if location_id is not None else None, + end_date=end_date, + event_type_ids=event_type_ids or [], + event_tag_ids=event_tag_ids or [], + is_active=is_active, + is_private=is_private, + highlight=highlight, + meta=meta, + ) + + def delete_series(self, series_id: int | str) -> None: + """Soft-delete a series (and cascade-delete future instances via the API).""" + self._repository.delete(int(series_id)) diff --git a/apps/slackbot/assets/Slackblast-Welcome-Demo.png b/apps/slackbot/assets/Slackblast-Welcome-Demo.png new file mode 100755 index 0000000000000000000000000000000000000000..415e547cfc0da68e5bb308fae5d74d9508ba353c GIT binary patch literal 26003 zcmbTeWpLbFur}z#%*+%sGc#k%5HoYk%uF#;%*~uPW?tvL_p7bi+N!PE zA0w%yR_m1JbgR||KkV1sRg98HtLzIygR{;b2s`j~U4+HhNq;1!?`&@l-R*@0~ ztC=J?{uCfAMC3)l!0O`QUyUF?WmpGkEoU$=B;dc_mtp%d)6bnoR%)6qs&-Zm@@6hZ zrbaGCpZmbTyh%-LtQ?f=t?XQ!NqKd=Nj*q;ndwPAKbOAr|4AG?od2&f3k%EtYW$D> zKUvRLUM;o`49v7cMqEVAL;tJ`URZ6v`{84sGcx!H#kP}KI}cJD1-2cP?-!w{nMqOD z&b9z(;?%bAJh+Z)G~x*H*bqeRMou*-7VgYl@Jpt?JUVH}zp_XLs#uKXcPy9N0QDpc zQp?AMmNnuyLd$l6mO!pH>#66$e%pJ_g|n8mmb{AK5-d_U5EmmGP+uRW%NP!BTWwoy zY1J0A?6B6Vt6JlC9JmFx zgCc?3(O}u+HAT5XV&aH{p%~%7WBh-%J_oh!5EjvNoBFx_C$fpR=6#;??*J~Pu9D;b zs34&K-^#GqYX5T@H!uXA_CEvMSw{VknEy(&W_`hc5{=dwQ{_-Js zT68uykr;3q&+n;8q5=*LNjh8{fkKkisqEJ6fK57tmR2*{JDaZDl{32;3dr}KUf692 zIlE_<0a4(R%Y24AIQSz{%YWNI@E1S&AJ$h_R~?V9&9b;1(Y)cKE@|HOUmJ1R%n=9( z35`qyt~0v33c=;&AD;mu=Fo zun(Fm&~YLA(h&eOZ9^mgN!!fKtZ?G%+Wp;y*Mr7etp-fXgr(08880`^ICL0@ERx7j zz|w(-zC;3e1VvWxDJShNfACHtGce}V1WURQ;(_aZuL(I6gB!_{+U4RV5kQRvxi!A&7yTmhY*@ zk3bM7=zR@JRh9N~Io-LxX)u8a)y*CYNxjv1N#fnIaT352RuAy{Twn@p1a7` z;mCox17E4Dv29M9EH0#4244tdx%9~U67w}XyU^ru1@6Wsa7!wq`5N1jY&Vz2mIA$6 ziEzv(9UWjKCw|Aw-iG9gOw?!+A{K>#y^6RndE^E-m(8o(>(pm>3o~(N?nz^y=2#d61z{vpz583V# z>N-{iMSeyhOu865>KoaCNuMjc4z@)SVMfn1HeruFeN^dX8NTV(|^wKYBU| zYoR)RLCfpWKlUVTF`Yj|LZ?vL4|jt@U-k8U1?d)q$Xy?cp2u18hJ}Xe*&EpL8o$wO zv2hudx_sCstfO8<6`!&^tcgR&*!iM^<3X2{8-vfPq)rDC_NrZBT{XDD$8_|c6}PI_CaQX@FBjQF z!MF9=ytO(?uHnVRr23+=jZD8|6UE~@df6Uv(~ii$AP*D|l8sBjLK0RiCS5wJ^)peT z%D|`f5Ri;6OT*ptLbRtiaHM}7^Hg3!VTiyc`^|Fo>sCeB9C7o9}kQCV~oYfoH<2+4HD_0V>+i8;6Y(LK|nze z^0L%7I5DWEG#V_^N?}Yv2<-27aJUlIRG~y8h`bs^rf0;cZKErCc_t0fi`yyX(+DFA z(%Fng_Qa{-Ty*FLv9cHHZ9j$V8TBJiH%}Am=rpwI4c}p11L{Jn_l#-Sek+uHtkNxA z_ebsAOTse33N|~q;KA3?eKR_Z@b~kguPHC~HOcxw{dld7x)tof$7Z=L~T`dIq2>`yqfGq&g9g4ffca} z?lrM_mHHVdYC4JFd+LD~V?DsS%xX=qF*y7lo4#0k|K3dwA;VRJ$}6u?j?Tvo%<+}e zXoH^95T1*+t@ID8L4&DRun#>8=8yHt$MyFc%jWvwW{La9DMft8`hy?`Vk{`Elt7s4 zcuAd;KM8nU1d392y+iU$h8i(jw&I9R^=^hA?}STeGQ$E%K_-afdS5O)1AFJF=`+cY z*sSOp06sh&KhRj&R4;w>yS6q4=WzIm4=C3(blSj~uyBMZOT%fI@D!O0g7yjv#uDWp zhU?4w70IArroJLm5B}O7p#ym(XyUivP945trq}&ZCZ$Ao*!I2FiQO#L0E^na`eAPV zfJH28Yz5h0+SqI$#i%Zn%f4%rQ)Dum+XC4=e zxO70F%_OXj$SA>Hq#OS%xuHzycE{}_a;mz=rZTGN zE+14S4?*j8>z_P;ZQqqzPc9GIkMaA zb$xnk6_W9fTBxXXY>bQ8um|7`jH{|2Df_E|rCH^(7)+;paFXU&n^$JTcYj zoUzFT+OOY1UpsG9xXp2xd^kll^df`e@V2{+360VFx9ylbnGf2Nd<>8BXqgKAm#2Ob z&{kskyKje?ExRzH^O5iToh>%$uncV$!B8oswWiqK0V~x{0lUM`L3jNv2nvS}(r4`L zUP}dx7cNGO+fmYO3$9mtdj}Y^FE-KXA1X@kl59)*92 z6szBrdcDy1p(5!0^!P0AWD;1~ zFm($)efnx(*}=@O+|#c*I=D4{AK;wH`a{b51dVm>@*MNKMYGsTO&(Z8#B6Y>!JxXz zJD8!LjJrCeu$B!iV&0{Q7_kj9CXMwJC;k4Q(7Zz1DrWyMI$rzZ=?zEx6&cmbl|@2n z7>7WyaDf~?kJx@gLA7bV_E&Zgt*RJMJSc{n0V^{jc8Q2g)G4GhKD>Z1LbHJ$!{~;8 zi(UKLco;K^7Eu%BdKf`CG#C)j-OTZBQLqMoKT{;u4^#CK{G$f)2S zl$iMU{A~AU?{Z(i2dWq0Tq zW%OJg)qKmYFxBE>?ngme3WL0S6MkJ^1l+lxi%oB2=nZdTsfuW+ud5CXv28u!$h3?I zDsmBx8=W-rB~AJ7Kqg16cQErb2KDxf(aqglJq6 ztT|mPlzvubyx*pFy%iJ*nweU$)^f*7)GWI+Gc8e&SDFH;h{SWb z8UUO>VN+`V^e4~-g%koH;r+df>v5LtFOD`M=>jvn*x_=VxZ9#w66oXxck3?5=gkZI zuhwwe^jji3dy!S8l$?{J9^e5{|g-)Htpo{EbQnyL5BmTm$`SONG=NL?0LIg4D>@s^RF?f85 z`exLeaTEJms5XXl9?Y9+>lx2Xc|sNct_E%Hoa#|y09V;+b1txa1<$YwotNW>L#0p< z!+Z6S4R6#Ylta4ZOhyy%dm z%6cV&Tkn_2q4*4|krAz%g@v(&2+@YDKX~oE=IJe z%`%ER$JVhx)AVFm-;iN4D0@QgUk}%R>x|ljN@CC{rkgGXepxNkx(&@4f+nXDu<9TB zkDbbj0*N00dLC3<$Q^}UauCV9EeN!3Jn8r;X8T4K2X39J(vFG~1NY*`>7D3i+9|I7+{Y@MWXJ87+9V;37pUNsBt804I8$jZ$5X~_08yyNqs#k_3{S#a6! zG_-vD!j_k8pd_2KU}f}ut)Hp$A0UpJ1iG1nm~aBh?M1CCiKuE?wo=}F_r4d5j=>R<7sbZ74f+#3!ExE%0g{&s zl8!0Rek=e)4hq5pPGcIs4>#N?EvQtAK94L6;e9w4eDPO%61MXO_b2)gtl*+!@q0CG zY+4J7iNW*FVZ)+QUv$j&MF$w{In>V-DpLg;o98|5X>~wV1R)gVrXaC6w4#JV^pS^q z8&U@mO>ar3H!f)l$LKMc=Iv#1Hx;LyKoyha@o0nvkv2#iM+0$}49Ic>jQS`C@bm@C zKZD=y*N1Y@EF(ETV=@_E8{lwm32Zxo4wSce^v(HRSP8)N+X?PZx@eIFb08durn;Ad zbH$h}Ty#4K7ruYcRbHTMz>U+DnvkzgB6g_Itc(>Yz6OPui}lfd*%Z!u%mX@@|GRqDNQl+Hg;_uTvZyQobiqagM0@BH8pehMm*f@`T*u| z746l_A?n-L&z4^d)dWs&PQ=Xr78WY6*e>inlz8z~+XDnMknK;9{x4_T~*MwAOFhh6%E*X7)P35K)s? ziXId?>H*i5fk+qfl_9bg1 zGe8{?qyXi%L66hdA=+ur;2QNCST_bgocO7b32G`$EuFr}8uZ1Cw*M_2-t4j#aj4~g zyrWXaH4lGRR6v6&sxVSCz^XFR1{?W(lP1QE9-_HXmaOqOiUb5Sjc8mEi*6^S~VS*D#RaG z>sn3iZx`VqGC7qwV**CGP+V>}sTZVv-MlIcZ?HG@Ip=&?$VHmGq+qo(ZR4MB+1Jh+ zhs=Dbcx|P3*4n>>1``ZdPLZ`}hsW8)g!?s;@k*_N5uOzoaoQuupb&cOK;_&z1ReZ< zw~AjxAlzK<1jEPoKH{yKuIfRA{;5P2=NQ!0#E|&CCv>ue(0h>yt#Sqo}dtj)`g zJq9(3yqzidY!+Y-VBl}J&#XkV?fGY(zde-v;6E7&!9KJ=#!ugQZN22*`h0K;;%4MM z&*P{irL{SBh~e-!0h;?qFWAex3{hkKHj#8r73Nx2de1~Oe!t(?5a{F!TMlbQxA`TV zTUzkSN(iln((t3|&{?n9m0F)7Zhjoyn~+A!Kdqlk&vvakNIK&SqK7|c^G_*bdW5k8vQxxSMfU~OC*&E9V7&Bs3!9HQ0I)m%V)mQC2dd7e*?dLK*oM2*YNi#U18$70@D z_+L`;ui&@)S@7Fu9mD%zZbuxp_76}o59QGZ7x$1*nC5I-yO<{ZzU#pvD;dxH2*>hpuhbJ=S=z zg6iNkD>52LZ@POH(``dd*Cpd)U%WN0AF&IZ%BJbN=geQ+el$B-D-$Ko>(wYk@&fU~dHnnL^0; z1ye!U%Pis%$kNM85BwyyO0D~b`|{kepmrQQdW?GH&Z>4Ew{%MY;`4~#k8%5Za03i6;^+VKNkQh@sGyVb&t zM?<|)2tTdfBh1sOvcUI^F_((q&~lih`;PdA_L-MEx#O4a2Xa+l!lfueXYqPI$VZoXo4{k=O}eGn-!Qd| ztL#3j&*%edVerSlBj*3wadk_N6}tTS9+JEFH&l^-BmZ^{xAHeZJL=!xdiDWTX8Swm z^QGGh1~UqsO_D2JQn-oL=SPi0Y_t3EEn`nnQeYX_~ruu-y-zlh2c}Tm~ z0w^(K2~x8BFwBkaZJ6Ygr8`pk^(Q2BdN!B6O&N(AYDGj=psKV}!_Z>r`Z=lYZ7yNj z;+RS-|0LjI$EZ8DDUJHpncwgx%BC<6aBKD-@c{HPzJ?3!8}f6!?|%G74BveJ(yTfx z1D$4kV~AoJhOCx(v(1C|KP$f%>R-6H6 zGPhbGYWF^#=~_8uXRtsB@;aSw{{+b4TM$2u&}oG-^Bi)uQ`q=tRc}@hxs4iNJY?JX zK7&|>&pua4CB-M@!pQ;d-xGFYVs1EC$D*0=J?z>I-uOOC;`UHs`t@hn<($E<1Dr?= zd7$q@j2Qk)W3|f}1!Ssr=oo^&UuXBcO|%o@rm3P4*=_1Hy(Ragp4}Kbyd>j>%|0drl24!x?ia zNmTR_Zh6NO6-dzaV&-rn&d@}_c$sBEpV^7Bv;CE!N$*)kUz1ba$vi$@gZJxC^>hj1 zn#E>>X62pRVI>Rnr4Yiu{=2a{Ke!ScD`0O*qJtk6iFbwb81M>*cC^BsaTY!C4Hi@n zr8fgBh^xNY_k<4H`3KV*Fd*6Zz$FdwiI&=PjDy|7>hKL>6}GrGZYQH}?(%8_d_%e9 zy78BY+gWF(g|^Jt8|Avpxp05dIXNz@O^2)2YL!UYnw9T;V>R=obaR(aC?;F+SouRn zK*P7BRLS0G%Nkk|+N~8nGz)4W_%gIe!%q11f|NJ=RkZv;1d2(Yf5Eg>wGGLB|NA0$ zTKHliLLdvV_FeDay|WTk@4y3~gj2})O^BE(*-whp?knNORQOxOR0n;h<0QeI>9SeT zMd^ZQqZB)vcG3~8+mob-N*41g@y6~KskQOV(ED2JX|1W|Ldy>aI=gdnT+oO22*Rw! z*XrrqN3^q~LlI8r=sil7Q>~>sSik#6#jXJkr1mo z_7U@sFD5&P!<}I>ev-zM_l`@FT~Nc~Gyb4wk0qGV4{B3BwPJ|eb^L@ZuLSIi}eF56`n1F|~I z*x{0~yxMAU*j2OH>2yyIMcT?2Csp~=G~5zZJ;> zGQ7M!nF`%aE74lqH#`2+$UL00+)-@2WOMUMo=Y5*bbwrJL(4T~q~K-^c`^a)BK4t9 z5is8#0{=uju_u*hdaR3!Qyp#z00kyipxUtMEHM1Gi99{%98h*s!iB^bC>BG)gF5~c zC^GZwxhP@&F8p!v^@3vEsy1s_vgX9GS&bgG>#}8!3g!HYF4{dkq${Dp(I=Vh`u&*1Wht)TASWllJ(YL2OPvjc zcM+Pi5b7#X5=~W3?My%`9Jw;%QV4z+obz|5O#(0cpMWyuYfKtGDR+qRgsc#KXTt6! zJraaUJpAWxOztDMdm8jS%p=_ILt>$wkCs(l;hPXuZJNH0s|BwwkVETyFK}2WaJb6&N>J~p;t^{Pr2K_k_ zQLoe@!f~y{dZs;w%s`4Wjvkg+^LmM!-osMW=qEHG(}>#E;796^X_R)}mmK3RBrc9i!=j z&^0!;-&@mKzE~TKg^!V-)O8eWJ;~Q7F*PWjZg(?7;TM%-P06QA!zJwc4nUgHS^GLI!#4S=H3)XWZ_tf?%pjI z+t?iW(KbopO-exf&444dUq9Hy6I>e{mZCHHw@g(qO`FR$3-%4s^!8CK#MOqGcB_<~ zC`hgSVVGl@*V|~>_*hevXwaq=uM}yn*B(e&l~vP6QV!ZvSA9OUj>k?25%_!MfFxd2 zkC$GTpox(j#w1BAMESJKrI0*g&RHe3iV};RM7mY;A=l`5^~B9L35nDnk)jlt)MQ$# zCjii`zvy=%)`?Yd15N6-<6*aP&9#q8gZ$!kxSF1JV%8^a%N3jHvJ2sGBk_u!Ho>46 zxjO4)0>)wD4=?l&c5C*p<_ee+SWgWA&7fy$wp+MXXcb7(5d9kpOeEzlJ~QK^K^{wA zJu`O+(|E@<%oee%`-*Xjz>;8_M11q4R3CNfSByLEYkRGro^ut*dvX;cdv`JEkY!SS zE=WuP#7W|sfOe4>lR+)%iTog5YU#Xq;jwoBQU(;9Mme1JPr0=igzP}8B#d~D$}0(6 zzGkFwC)IeZHHOVj^qo2tttil)b-cd0L+wD5zGRR_i4uuD12APCcBC?aAUu>$7lZX^ ze*UkEF|J#&>Gt~h*4=;`5$L?<*J^lAQ8o)e|C*G57&;;nVeJtBMQGa+LOQSIj(PUb zk%@V!WBIWKaZ4^PIfaR>sx^Ta{`Ls9_PnGiz;+;fzyP`ewzzi%A490lvy{ zpRY(hP`0F09&uk&+`0(x?dEL!n)O~j+7abZn{!u|L&oqf79Ey|X&_cE$)RS$MMN?# zESPiT(Guo^Cq-eF30CD(0Z3(pZ6s;JCHr8Bjze9gUB&9|zz-%nk(XlZEKwlNVTMY} z7d)4q-mhk8g{?qkZ?zQv^4hWM`^AnXd?E%rLa!>Or}ayWmXH8cwwtU>Irk^=c6ZLm zp$|g@TSbgNReWreP?|CR#8w0iDW@=2-XX5{E^vSQBv7Z-Dz0ZSTxB5Pc%3P{e_xbk zW2DhVK4Y#8Eu{bnWoiem%mvOP9LFXDZN^mU&UK#pq;?W7o(>hQ1yUqQTAY{xG&52_ zi9J>Q{aeQmvJJjdwlQ!DbAB1Wi&T+$^3t%S_zjl}slVCR07Zt1q(=#Uw45dfA-)@RYO-F~QJe#knY z_*l@0CsIL|*e2~hskYa|uD2lTm1x>wrv-E7*sqhV zKTBpKQy8>y{pk;!pZF0fg{E(*b}` zil>W`IGIx~BWa__H0zsUxNV$U5sX<@EZbU=_le|5@?gz8cg8yddvi~wMx{;oLU`-) z_FP#axg3a~R(Rs8re(~}P=H5)>1s-5tfZC}yogQ;m2YmG1RgL#(0LW#*q*Z%V+l%L|RU(wB#8Z zFeYXPxayOWBObHgX+=MuSvz1z9_0ivpLr1S?A8~1w)E~0j4I~D3FQ^)EiSs%tP3!)bHA8I|4B_#D0UW zjfz9-WxOY=6L3zIIqke??LP{i&ylF=VIw_9C5`xgSt8?_BBk7CG9YUpGb&S2(u`Vq zpGJAofz|* zyIfSzVbp|wMWvjBhwT5lzCEpEPrH(Fl7CV^100FdKdQ8aaWbsYk=j@#dG z0579s=(8}s(NB7eJjNmpMWn-fcJdoeC%hMp+-dZA=~G8R7;mf8S!6`Imt zFNoOz?KRlvkf*;nv_B&~`3CV5+CZFYZ#&xq=z`!tPJyOKl@T`y*1?IZ$P&rivT1f_ z2NNDiONFE;Rr>7^orA!qAU-}bI7DA^#cL2qSWoHg%G;mpxqer5HOz9&-aIL0b5W? zC&|HBsIed^wl3q1l%O!kaQ2qRCDg{~U3o1fwl(SY2WND7g_>&ID&%ZVBfI~u%Ysmx)FzGoY)FCqWM)G zh+4T^sM;UwX!3FFfH=U5J%8QJZB4J*@BBV`E1XT;LgUT~vOrX{y_dCG#M?@?X;;*U zG6`c}JR8KGqsqP7-8@6ksQH`l-V5MDc#<$Ta$tS)8H-A7^! z<2jwm+tAhVCV_m?&|-#f65i4D8pb>I@{uM8e+FHUe}?1S9PWsI-;x5jY&Q?aCHRjf zm#;`Z%K=XvHQPdTrA;u+KEWdQfD9XH${;)$g3WsXc zDWI#}_}0LTw4e|saT8XJl^v$cFfY~W>x&>c^(+9Qer-_~7_4Ubfy@oW;+j^Fm^2^4 zSke`U)VVm9e1UY+b8NqnI&|Psv(cLjQfi2WCwUy*?jF$4eN~b+i{hZa!px}{7P#o@SAJ?5?<$2NY>3YC5?g!MT$qbIIlx&2;Mt>V7rn^>E~EXN5(M~(Ij zv9W2#-*2e5zJ_|C4o?B^pAlP^n|JG2e&ATl(sX(S+V0v&^;xW`$+hGLC^N9W-l-y zb2&D69ykN=&k|osmFnFP|y&s!e9ccaf-oxzGgxSfo`#AL(ZLtkeSL@HY!ZpZp}FBLc`#WHoz ziXBjggJff5h6kDPK)tjpnDvNIJ-|?IH4EsAO>Rh-9V%w?!g zt#~Ukfzr|l;T;VGvi@)y=WYX@37dVgmLnH~+^m;SJ^Mv}Psfy)ix$~_&w9GXKZ$Qw zYXUids8U^^#TSoU6%%-lqC&kbfhl;B?R@grbMe+l$1>SqSa|e0`3kXf_{HsNb^YJx z$xHrfyyFD2I#Zsg(xWnitXzZ&WduVwNF zzG{TVbJFl&K#eMjYiFJBK-ybyD4b#DDw(kVTyA@pre2F(WE-TK-}qB{J$=a6Oh1Z@ zhG|Nhe6v&M(yoG_`U6l7h7ZQE9xOi!`AQOI1Hh@ZRqm#=?s(mCzT60WmpX7Io`*yB z=H|ZldDd;oT;ZV3`zACGD;6#nN!-Lj1N9;#fK#d3Vq#H1!L&&5`lL;m?AhV~n*1|x z`WM}+OQ`&NP=s0PsSmYGC$u=_Znj||jgV0_QN%q(#P4$qrgr?i*=d&8SqN?#NW+Mfe^r z%p721r+r=CmN&sO=)z;epA@M&H)!0f`{?&N)qVSf}r$@S_&>fN*{IqnxH<5IN3X2xleMwXE+Y$I9o# zb&7U-!1?WMNCXixImQZ=oE82?T9ZcE;F_}eo-g#0;x<=5#Kw=`W|(O3tjfRdt|ig& zjcIk-%>_S$_2{9}I?uNyZiWWjHn*^=Q%yH&9y)uNEsvq+O9x~Rw!REAa1zf(#(iJJ(%YZo1Z^E+=Ya3Im?@;n01$R)pJax_8q-j48^ zPs46cBUn#|Rmn*rFn&o6#JCnqjt1rRlfPX&6}5eby$EZ;XbyzanXDl!DTtwPIOgzc zMN>};uKD0^TpRfaL=TLd6}7~3if&oa4RjRkA!JeN9FS(oVFL4VL8G83oDj8~SDLU9 zi$WAl6}2U606LHMId#`plYgt3`ORcFk$xQ_YH((0iHpnI;##s&!x6>yfb8H$ARewwo5-6X)#eI z21#sXQm0samr{2_lPq?I^yr6Ni z89rj_Q5vc~%RCI7P3qv4zTCe8$Q=f-;j_cu(5dG_Xg`vEBO`gIZ2dndTaq^U%LPuK zc)&Fqt53$3?X-0hw@xkaAL4~WKu6OK5xc`}ymN{uu9aK(;9Gc96?!a^Nk}PJH3jOR zeoSx9feHNdaz=Td+>N6jWwcq8F=BM!oxRG|V;P~gLULj+ZzNLnHV61Qz>gfG@UsQ} zeiofO6vZpB%i?y-&2B-V1MoYMIxvq5U+2l{7*_ik(r{Sl=uO`2;@Y{?ksmaZh8kg7 zm>{@cLIBEA4WBDxCrDc3c89s=$@ZWkNYqC)Hnh@4U0CX2>I~aX?fnHqi*y)kLRv;% zQqzAV;mrIjXeFO4zKYyr`M$L|;}+OedZKTEU{jzU3@+Fo^ZLH1xQ_lwdV4~>`aUie zDTSC=I2*lSR~I^cx>ZgicAq)Ot!*DZd>{~fbR@h)sL*>+@1-{a#Fc@_$A=3)@v)g= z0Af!trah=36r|EPvRH65<8)OV9wK0?W#vM3!P1%xdG=Pkk@6yvsS9CZ%;2E&K@$?c zqcY^aLG({TVjd2Q62;nH4D=SWLiT*;%63cime_hioa&uY*JdlO3_o#T)CcWGh)#|a z!sv!qTL*2|#2+wm{3PIu51o7KArSfVX`?hSI7qhgiW}pw2DstLdW5Xv^hxVu3N~Vp zJ#;w7vvuD^{GR2v?fDN*?fc35J1iCX!;se`K|ry+rl*Z0r;4Zw>(ucHD&GDDf;_`9GP{*gNaTfYNOo3c3acxsb9`Vw=65fy^hf6;bMk{Zbi#LohWbDbeaO{*F99}q00?j%n~GLPUqaR z{{9|9Tcl@qfpJVr@zjj2p#b{7JM};(bo7quKTovLjXSu- z``yL(1bzLh(GxbF&}d1oT@RIBN)i6$Dqyx(WOvQAlerI9DR;8J?z_-L?S+=gr*V5f zRHpmrN$P&t!>RFZC8EGZWN{JYoPbTUg!HY9%sV&8Eu>qdBwpMoHRHXbiIOft#aGEXooZX(^R z%dayDF`ZEi*QvYI`tdJ^4cJ++6Ud7ZJRE__BZSQCCb9-O=qKZEk_$zx0wH{~SdE>} z&|zK$Q{?^AAsR1tF3fKd7mP%1GFduKt`Buoo}fK0yp$Gvs-*YGy=}`nqsH8^`Qe{GQZ#w;>eJgo@rZzs<70vPs^Ma* z2EuNk9=%F6%LdPp`yF`Rb9S1%!x9)0ddG)xA)Vq7%DYRD`19;EY*h+G1F}gr<-qr8 zZV>BU2Z^ngm-_i2KFy#~1aw9{B|5RvkBCFO9qmZ5`x{hFJ-N6VuU+)Gb=W{-&&27R zym~Koz(c#!3UOU+mDI0HHAzj}wan1Hnz*j9ZNfLS9OMTSpzN_nk2y9j#zbhpw{Erp%p`uN`|cNb2L5b_rgw>>n{S zjVG<6&Vebc8Guv21>>Q@Qn9Y+$Zcn?IGIdJRm|7h)%s5Tr88rp7K z%05Eg{bEXDK#p%Ld_E=E%}1QWX#O-tO26DuAe54G}`jMx8cih5DN zeW9-seYG~?8iwahdOen;KFkU0B!6yfNlEtnzJiK1ticu1pG^^lG6cSqqx`F)MlP9o z>XSN?SkKj0TaGcRPKIFjkPc`aUowQ!Q3HL?+&qLx%?eV{Hr3GBPi{pp9dvj?#OgV(ik7e7_GvakG=L4HO#49l6}Gahwv%huOp$yNI%e@QyL7xH>#27 zsG*8h`m9D+a&WBRb2?g-M$rhSjirG7{Hj2}UX}>!JiM$_1wDg+PzD|Gx5*sE@j;9; zM^ma#ii4{a_uO7sa^_-$QTh$E4UDtIZn%{ue%F`s4(mK(Ds)<)@FE8Z)0Ze#+WcZR zMKeIT?2N;mw>|nEjEurSeR(QCC^>?L*Ikt|QFM=6aY!3xHPA~g;`56D_NTae&Zv77 zQfGneELCkyN)SU^gMJH_Bc1)I4iv=Ji^bnAvcQ8DK%%#|yddNKtuxuic1V*uC@b-J z1S?rIop2Z3k=cEW)c?~mPB6qB=U|ZClzD%QQKf*Dqm^P(E~LNd{mY^JTN_h4PCw|) z)kRLPE`B4*i=F;A!`1hAzaXauYd9?o@f>S?5z(}-*jg)+0B>R{BjMv@0=Z9=NxBB} zD<1MU*KDmWo{T{C@1Mw)VP@rt{)KhR%=zR(@`9F6{-jn+5W7=UtQaNaARi?vO`oy! z-uCZ9RJ&GRRt>pQAIvODEj(FNmTpE%dbXlFM@#?xSeo&v9j6}=o#;PD_+L?N+3(I< zrs2_DIJlb)xJZv>Y2c?Wg)O6Y0Fm>Bx<5Oqr{4Bx_oVOl910O4&E2a0w;@YcL|kd| za+<7}F5WNJnu01d3m~aMsFDGtGePa8we-Rx9a0;FdhZu3u%J|;$1Kvfefu^CTk59# zSfC2{AP9BWLMRCRzluA{uc+IGT?wb%Z(KkWbEn)zMpIM3s}@N~S#rFV*-&xJ{EUdm8hb=*l5ZwW1i$*&Zqa| z7%186QKg{C6t21wb?mAFI^JZRZ_!CX@r1Of2kJ0rh|AxM6h3_)<*S=mA`w2E-Gf!? z_%|dS>&Bq6nCLnOGu3lzaw#V@aa==j=U7EYl3S5DMF*Ioxi=3QYU`)Mu9kfp>BGhI zLhI`{k4l!$qa4gS1Mv_rSb2DSEwTv$I}&%Ji8j^lcr%C(1v9!ng&Y&*FiA!!rpIbZ z*5-_Nx)Po&sbTiVPD|E;!3^bc$wD5>FIXv(C`1$h!B%N8nCTO}9H26H@cE1II8#$) zNWvNb7)n>Edi%L;YHxxu~zqQ>EiZlYA_T&f$@@Me&URLE5PvjrtRG+{5Ubin0Z( z$`;kZEP~`3oLJcY!AzPf3GAZJ$iW7ZJc~G9mOCY8FL<+Rna1x=7H^JNni~uyIwPa5 zV=A#FzG?b`@bku4|H%O}kymPTO71n|_eIA^W>yu>Ja^ccHXIVD_1JbSBF_vs#ScY?;1M+rJ~zg#fJLjG zdOcmfmB)}U^CzGI&g7?qGg_z}nu%siFZOcE6z64Dj>J*N=8vDaAN9+s=dq5xGZ2>z znqffo8`#8ds(%5NIG(4j(qPLpw9hgexpeRYb4fg%*k`D1%nWHTo_hChqD>u7}pdbdB(8-D+(?Q;|O zQGcgX=|K`+`eESwu5hN5ve8~IMIAcLi`C8IOemi6eV%(+A=3iMyd^$ej2kgA**W@_ zt=kf#Ak9aCF$T?~69o>1IBvA^`7DF$OU~lvKi|vCs|7=E#O>3|jKmHb z2)jZqHM#Du=;7$0aGA;TOT${e?C|+tb7Mr>Gx!)eMD}h*rt>z`uZhykj^l{iN$p@5 zX$YM9y1Oe;>1ontKV6L|B8{j!4&z|m!0A3pf~WxBPKhPfcfZ_?m?PTR%EeP0kM@2z zu#C{n}vfN;B=GM zKZVFydYunt5N^sGZ{CY*U7HQ`3+#4s`p-O1Lt@U1CPJl$(J=s{Ugded-E^q4==@CH z*nsEY%gAN|*H78n?J8G|76E+|Q>B8-qZg)rHjmhA;%lAZ9c5$tz9mH6C(@uHap|!F zbFmkU0uWec5E`z7CXws4;FibzsV&udQlAO9&TWHLv_kvJ$sjgR;t~3|`udG!3SBeE zW#P~D#8IN>G_&)VN>8XW9t3|HKVPmd^YxM7w9f`obe$XGT)rKMu3#98qZ80dIu-$? z1N!U3YyiHmym75IhRZiY9V*;~vdDEt;W`#|^gt~YfLGAGl3+=7svQ{x^hHD9mo+lY zd_dHEwx@z&>uaSI&jQIdXlcuakDKn`ir}CrtPcj=6al&{iTz~ zWz&xg9ARBOK5}5kgT=M}#dNrsmG_6|v4&P(LfrmzW=h8U+!FY{3Y-m^X!h=0NRzIz zhLuQA&{iVsbQsMe2}1rt>pyreO*HlMstK{>^K~%_g$+g{`p0*moibqV*?RuV2zTOL zpF&n;Jgqd2Vq9yFUC(`W+Icm8j}}!D$3#3IP7wE9QmkYP6Du2~q}Tb!ZC$GlH!4=E zt4`+DsTFyj2oT4p&FTOnm+6**YLt?!jo|X5!)(t4-5RGCKP3?A26#@NMMkU?dM?Cd z66+_ow_kgv@=q`<6LgdQ9Q%XP_{(5F6Glxw2D8>els4gPyNqXHT_$xQr)1HMBebo{ zYR!-aScT*~c6J*+Q)XS7!11S%T_Z$l2`}EwjE0W!wYBZM&~Zaqcb@!NFe;f}&{l7l zEOlr`hv7XB(b>_|cbf6O48<2J-W-qg1zxRP5H>u!QQ$P_@LywVZA6#f$cs20LlqXW z1dhy#Lni#ocz#{Qfsh0)p@Hitsn0FdJ+I_QOn~mrd`0opw^|hes%~}SAzfUF1#rdN z#};oGTjWlp&56@y@L$@_#IE1!zvfETq&5{N7vRyn3Vh*26iuNrnq0W|oJ_)3yfvk= zK*}Kgk3{R|zLbXs@4YQOZafQBX3irB5hW~BpNBWVg*kMs?=!+?gI~i2IQ_ef7RQm7 zqY?^qm&f2ZRUgufFi2ZasY)*;Y{%xo>#HiA8A!Bgi`{yW#IdvKkWzjSEOzR}j%dwPUW@FCO#f21 zMGIPv>xwN&{Nq{&DT+)Fh|5KPHdT6l^;nv8;Um5#WlQYJ5qZ8E zlvNEPiwmo_RWnT`}}D zDLAOzt^!Z!%Xy1+u+pU&q83={HhF7u&@)V#lB4VJ{e|3)xLG19NDq3`Y~$CnDA;us z8L;4J%L=`#_Ip2;#n>?NwFD;GGCTyJ87i0@AmVrAx{zWc;w1*0)(q% z@0-}YMi^o-oyhR-S{mJI%4d$Oe{EDi$N%e!8hV_KSstKiH$$3Hs`LVZZpe+a9fQR~sdapI2d`~HA=)5CuUzWSzMw__5t=r< zk!)U;lZ}86&V#D*Z9@cy-{m~uH`KS^(mWfYR+S5*;*_6%6(pX`8tm=c1{>abqBc&> zWOt5;k8ls4$9c1#T`C8x3r-qL#H8M>FB1GE_b&f|sL~HN$SD}F1>S~$qU8!CKYU7g zFbC)3Ny>bPma=#&`ak16Bx$O>A97C^q*;x zM_ot>+KX}gbjV{3SZH74XPib{AOAFtufK6)G36hOUmcn25T1E4L%Ix}|#>AKTC5amkRG z;A4EV9h`0L0z#8pDLgOM-o6~=3F4KYV^H)C?N^(m;_K|Ab?E<~ET$t;gRZxw%A~s$ z4X7>!aE9U5jc=e?5ph&Euf)?G;&0ziLRv+J_&B9wHm5E7!e#LjaQeA+Nmo~t#YT?& z^9X(350MQi#Wd=sNldL`%A~Z74^i0ed+rl@bd^P_u&OaT*ea3c+a9r8KpErh%HkCI zZp=E_X&1t+R(*JZTI^I;G`z_x|03+dQ$JeqEh!Rltw+dW{6eLoHN*B3!62gkga-2Q z5Dc zI!jpNLKnP9FvizwU52Ef$7*)mc$NzB6rf14eLWr>nmS|W5kFMZ?8>*S!Wj}yH>uVn z?ovtr6a$}D%{b_MVxO^tPk@;7On8<-&H-Em&ghELPqaQrH&r+KL#EGn-0_gfY@1#g zeMQLH#XJZ<;pu4+=`<8i;JRtD;t5}n3>EzO4_y{lYl$p|o`?~gjv$`yE|RjzeyuL5 zs_C_O9)gXmA z7eh)qMLG+WnPEwV znb1h(F51C`j??Luf>$pkoAtEJjNj)H&CP^smhfh4;^%%G%s`It!!$zsbYh@vu!2jj{EYx#Ep@% zRpitoId`Il{YEOaqq_@er8+Ycd!_{9<5Z46T}PXHfx_~CG?O%K z-=TupP`*cjSdbu|6op=FjLL-i3a^DX>K2!39b>@u6WEvls&J5T!W(vB-Lw0J@PFDf z0bO6X@v~Pp@UK(-jft;<1!nn2AQe~C?Syil83QLpAi^y_V(*^Q2@`p+R>sHjoKw4t zo#Wd|P=;G?38OD*eN0IJ0I@fdtdt4Iam%{rD#=;R$g@EMi2=5z=M5W67$d7Z?Rc@#nFa~o|ZC6L=jCefSxpDVgjr8Sy`8R4)qI}e5Xu~ zZleN+_FSKA=kGIXHk}=4HKCGT*}P!+2Uy@`(9@MvjA4?^YcdOtQsrhy!e$d-H(dTD zXF1MdAgRN+gQXQ0jmuN&eXYX2!WZmqG`j(B)bk{KRk0wD>i*JHZnm(^6%$pukX;=n z8KZYcoYof_A+ZWhB+8?ugLS)1Pm zuI;!!;tEH$yg@i4I1qbz66M7vDNK-LMT{i{4!+*X>H&e?ehaDOG2U)q~yC?D;rmQ#F;N9-Y#SPbBisJXCBeWNznc`~`ma3rI`n@Xu<4^K*_L zlnSMJJ?Gza6-%9W)LoFlyHQQ(EI!HI?Z=ybbCLJZCb=xHnulTmE(w7O4k6z1sLE(o zB@q-qzKLIf#KZ1)&W1_ghnjG2-Zd$YL%(N-!L^O|<~WY8}9?>`3?%OrZ4`)L>6+$9iLwZvR#~i+Y}_W3bmfJv?v0j5D-quA(QNT4aU} zkP^*u^zh(39pDHr8`P0#xnv=^&1gv!8;n+n$|y&%MQ;e%g5gi zrL6$7NGt)>*09@gOV!QG=jj!pD~9OMX=~=Ld8v1WZ=g`Qan5b<*CHDkrTvJ+jJz&!kb~K_0n25xOyp z%JXR&Lsv>YETs_af=Opm{CU;XCR#9?!p|gfw;BCJ$*XFmh;srrn z?6l8MIUatk588cmT^#(LRs4j_HF|Fi#iUg_+cM*qf71Qs1c%(2Y<}Wedc7Ge?$iNM z!huiz@RN|h7SAbNTO|)hEM{g;WSP|}rwz)ehxPrqRw?*y8D+H3i7K~oQP4Nxmd2IvC3*7!dK_%&6LL=9}uq>+Fw2uZi}`-heuA2ehd zktoEJ^eqmR+wNCWTBj@dm-J4irzCr2oq1nYXtVL^NS2hjN^M_z^O%O&n)AXchH|;9 zm8ys~7p%a6#VvS&D)y~Ztjz4M=rC;{v+dl$PE4GQm#mrASHi7&l&in5zPJfFFgbLpa>>+z;H9J31RZ5~}u5Izk zjYTrI!ySfw0k5s)7+WdV`Cs|Ufyy-v3htxlYm9~CN%h3IgZ)5nxE!E%vc)=DslE+S z22T@c)j2uKLX-{za#=4^{pmERsY+?aJ8kT=AH8Y#Sc`^`P+r0xW8w+Hkw4YV0&Lql z>dsXvHA1`5gLt#{hW4Ro2>al|l+s=}!fC3hx-HyR7{0!=Pf-~)(#4rYP@MYB(~~r^ zzc_(koC5h|u2-z@@EIyE&-v6*dbRX}Zh_IP){M4R1t7{eJY#&=X7}St{ccqECQRE0 z#*0T;eA~tWA$uQ~Lwl(9w(QC%=IkX`?6!<4mQdvxE{}pvc196F*OI*jQmKHq)Z8FS zx~wTN=QZ8}|G5XxklweBm@0~X)s2eCfAZWGxnxB_+om`r7ot= zWVP#7%r6*qzwVVPQbzwG_&0aYpoC=Ox96{yYMSuUx3GixlM(`?89ts@Ggxl;A%|yu8e{xE&|>KWgGSs$z!bytX1r2Z%-Y;&pS&S_i4p z8-c)!+mr!51sEM~kAR@>GIuHaehp%Aj)=)6&6^!UOD-(ICF_~Gx~9gAT(lOd>zgT+ zj}qJD5HrOEUEC~(6a5FCI``QAT=i&xwHRzrTx#H@Re!MZvLH(oPS9k%<80?t`PvGQ+tffC)*@v?48qvP|@3X`a`)ykVtGVzrfTV@%S*E`05&dno(qu@)b$~z>8AWIgOb<%ql!D5i6Q4XJH$u9 zYj}GCI%@$_I}34Ss^}fHRUimYOIc#;@c|{DtHztr5_5xqFG)Z1ju8U=4Cf*%)D>Ju`}EvHVi40%Ap7amJx<- zN@6Y~>pGblDA`&xM`M_Q;&q3=t5~ryb=KxuISV@)h1{~_IZK*zz!3syXAe-;xSiw-EjyjURGT zF>%!^RO`T;pew++=-KhaDo}9KAZVnAPE0A-*=XF8Vlz(Vc>D+40aTs!j>d0jFAq<% z@3m{pJC)#rU}N0*m`zTtH+F3 zc~&iJSEmwOQC&9rWeV?ShGZuAHa=ATAl;eAt=2ic=ve=hEVz&kdp4^MK#Z`;0{5s&rIK6Y9AROpQS=|onB^k+VL-=&EDw%U`-gEp zPB#wCyo1JPB_gTD z08L+Gr7$UETUY13RsNSUhu1Ljm#`7+!28fVmuSsF7r&oYbX)VNqfg9Xm*oktB;AaJ ztZ8{&bUVUBhehhu@X(w|9Yq;>^Cn$K>2uBd@*zj(D8c42ju>}b$6UKK3$+Vl*NS=b zP?q;z@&m*07@>Urf{>Trn(m)`?Xaz62Q%oere^`C3)z@YMco7z;(g!9zxU8=o~8`` zJ~waHNT*uSnp?v|*}LI9TnxgROELFpr_iI8<>OIGSqu#rwuLloCFW1}0i6WPCcdOe z$QNBtzQ)V;LS$d6oo>g-6`fe__0yHEBwJ#2>fcXZZ?+bBkn3nRiHMx^1ZW@9n*zCx~DAO zXHDhTjI4U2PS~BPy33*1#rNGp&Xcrtif6Lkt^8(Q2R#_ehQE72;r`b~d`b+$t?w2! ze&J1_MoE-PrOK{OZZL^X^T}?~sA!os%nd>9fZ^F6YcK0wO8V9RCg1m+JDxTLY>hHs zg?F6M%x0Vv?Z}%+!;x+$>kD^0=0AT6Kd$tD&WC!-j>DV8`OB=izH$`^sA2FT{vQut kZNdN!< literal 0 HcmV?d00001 diff --git a/apps/slackbot/assets/backblast_demo.png b/apps/slackbot/assets/backblast_demo.png new file mode 100755 index 0000000000000000000000000000000000000000..161b7212d0b51a10eb0b9e1a5047dd8f3acd7327 GIT binary patch literal 46454 zcmdSBbx<5(*Dsim0Kp-+2X}W5?(Po3g1cLSySux)1`qBK2KV3$1ZQyB=Ka3ARk!N? zu~oabc9*KDr>1ARr~9#UesTy`QjkP~$Af?O?j4e}l$gr9cTkwXEBq4-a0Kz|sszxy zcUF-Uc~>(<00!QCv=EjTe)q0E7U9Vl8h8)qAf@g6?j3Uf+w1+XeVOUIcg+aWV!~=3 z2LINfvoQ9EVa^ZQJ0ed{CDc$YQKiM>U}y%vilcQv>`5b%z67KdeZrA)cX$3x2&?D* zw7Kcs=-{sR9>E=UI(52%ufDOd(fd*Gs{3q0i<`I2^lwZ1B$K3=n2TmT^Qpa%?9K~T z;uqs~G&HoCYE>7rQwDfU?aeuM2IBMFD{r;)<|_z6t|hb&q2;9P=A} zmjF$Ujv-N#FJeW_FCY*fM~z^b_Jx$R9bXzIj{Zyf*9Ku>CnQYJZhTTw#i~YFSlHny zh4xq0pQeEUdg!!85uy)Dbs+)3@gP&EQ5a~BK;S3=A%SbD94YXJJ_?h7cIhl%7S}Ja zOp^$Fts%kY3oeNekBE4PAPO|lG0F-H3kyfDosYWaaG2?A>xbdhZ@*#}?_I|%0v&q^ z+)Tl-qph1#KIx+rk+=--oNaBKWTwQ9@{;nvPZacZ;L+pIzMVs927?d+4;*6+MEtK7 z<-ZyP1WBp`8-1?8Kv7iu-0z6%yvQBYnBQ6?!^LRIInY zB{HE09%pZOd4|YhDvL9uCRw)ZlSV%2C{8;Exzf|+Y=B;WkO3|n`cX<&R+J~7qVa+- zCKRJ?{}?GDWUX4McNyw5CH)u&36>ySrgs773&-SV2A^u?yiQD_a3=IW%fd1&@{?g9W%u( z#xo>RNTX^XkEB0}1nAVU$e@T>TD9Ho${Y%{w1mNvPneP@C*vBjB*J|zJ+~t){7=K$ zdQz^xALZ9)dK$P`6(mU+7?nez5WIDlG~hylv!nfxL5cBrz8|GzMcTc(i=zWGu)=u! z&-!L1^|x|}0)tWR@-_D0>)1ua7aT~MG56@y*+PJUrDiHoB5^{%YA-CIQCeecgNBFu zc%^ACR=o%J{WK#&HCFR?-VkP-A3qfCXn9>zOF(e4&iWiXIdQuVBmvJ?PJlVvd!;Owe$;>okNU2_#at7eEx|kLflaO&> z-7wbNUCul*w_0Jq5IOb^_tYrYQXsr_qI2Vf#?Zz-l%^FTxzdDg8?woihp}?>cm9PI zTzc2+h~OMM+xVYNO={VGRSY0wOWSe{Re4$2)Vp^G)(AyqU>3OWAqKemQE4d!X;C#b z3{b-7nNoSSDx0jI7Sq2>h74D)t<9^e-xiI?n2OIny>#t@8isHls<&e3PQBq&5b{@SG6n+cdIo7k z#mAgMnoU$3ExDRTI^5&?9t9#Ky3d5c`Y0yJVk$I$D)spM(2t2eSm^V~n$jra2SzIj z0ea9ys3yr%P zaQgdVBM3ygsN){I=Pi~ac2^d%@T#uZ&tQhcLHUT&r!f8|2Zss2%gR+W+Nmu5 zh+lUecRtRaRISPL4UdPu%FyQM_`S;=`iLj#td(alVOeFpT>~YKC&z$^tD&Lgd`C?b zRb9{*kPVNC?XZ6lQJ3L$ZdLZHN@Nh7qi)ON7d&Q*l_1@35Z>8C=yv*}^>%3w=ieFq zm|x6g1bTW|Xe#ZUejAe>W+2(`pkMJUE(|m-)7gBnLYmcm_B{+L_B4}ePS$gkn%GQ& zuLN0HS=Z-^Laz~)n+l7u&Z_M$G@YEDpM89NA?G2{nde8=_J_lsxU3$iM5i0_LNSq% z{kmtJp|-0n$Ht@u2}j-DXT8t2>!stNoeDYByC0*SE(f56UW)v8&R`aU;N#8EF-K>J z6Hxa5eF}~YBz%~w4cH|dM(x@A(#>s>jZ1Bcq#nVYobbN#n!Tvh!ZfE)#IAzbPQ9KW zL@W1CGfG2K)Lr3_iu}D4=8_|<She#opL@xF^n0`|&Y27hcHMfLk`*L|MrCOwi;OK?pg;tyl^gQqRDF%?64iV~iqqAxcgz)Zpahk3z2h-j z?tu!fLMSn>&YI<4r2SA=ExCg~o|BE@eQ?jXp90qdeYZ@HKSf5l6j9~$p(j_f?EI}h z)+8xczuDMJ;piPptuqdMIDApkG(p7F2)^7?Zj?ac=Hu(1nR&mkfM_meY9J`MadbO~ zHp9Ylcb~e+>^*gx#^%1k(K_9o7JyEr&^I&$(=8}C)M&pEXp4Z2K?Gjaoz`8$kS+_0 zis&mjH+5k;{@FBOn(Z`2Uis=WDT+n=&gWzqV~>l%Xd^$-N{pADp25|R`d}*p6n?OU zq;6z^gdu`-Q$_eg*#I`)_$>UOFe9(uy)?cmhlwdACnJDp&!&!V3VAD)lr7i9JTjGD zG}gcuaDVQAc;Hu%eClrphmwVjy7BIN~zikGYX#%kJ$fCQ1JdvwDaYN z?*3*bt{df<*c;=l`>G`8wasGafX`n86!oWZxF3h-+plgUqHFE|XG@v5sT@%ll!4H* zo=7f3f6UvK_Te-Y?gqgIg9bmR%)7pi&w|eApFbBoejl+O<&3j6&RFd2C5oPJ$l$a@ z)Ya7$P+-JbthUDPdRdS5BNP+ks_<&A@ER^Ila?A`(lSND*qqL^k75w4vazwD!jI+P z;?nfpF4Had3GEJ(g@R%KB;uu2RHN;ne6jD3ZDY#yVwD%?{j6mNmTGYBGfeTjJ3KV= zFyIf-Xr}9B6u{Du8npJVipwtQxqktj>`N^KxeM-|r}Amf8gH*TP4#V zRuY{q7E{+X6p?Qc6Rk$sJ9~S34^K}LW@gmtQ_J$wTK(1+8Pb%mUr7F@m*$FOuh4!k z4=~(k(rt;#>5<+UT55DB&dMT~X%N4n3wBt5;Bq(x!DFr~7t5HbRj8pU$17_!x-pz~ zw9Y5TWaHw3Jr470%2Z1^eSCaSQBi;FPiC0w5`z(~Xzx^2RO+uvN+z@oe(VL!#BJC<^I1 z9~jwMZmGRij`yV1toi89#YL~*vX&+y=Hd%HAPTSRzaKKRYFRw!6{mDZ*jp=I*l@!_7$mnv`b7sIV@FMMU0+QzP4ofUmFrL|_ zcyK(JB2;dc!R$hEN^n&9dh6X(%L0SL?(kznlq)#6%JfC0Si~hax>G8&K%-jc$bl?| zNFZdo`wobXGFHfXdU{d$at%IUDe`6l&-g#H;=yp9es*WlExm!Z#9upL+0cKjjT1LE z$-;x>v*Y5*L?ihPS2#^fcHpZ*$VuDLRQO0pM36tlWoHKkZ95_tW^rH*c7^m6Nr{N7 zeg3;2zf3a=8bot*9q(@t`OxV_9m;<_cepo$gH$v%gc<wME0B9}a-(8ICyy_s!3XYd&**S2`OT zEjOLkhZ-A-&b+Q$6M9@jNaJ|q6Ua)O%ZkF`#Mm24p6^$=W72Pt<1*Vdzs>2=x|^N$ z5`?2ERmkQnF};aNSxN33X`_Wf!VMwmm-sdcGV{Xqe||>k>+6f=nPctjf^>m*^+Yyw zLvK!8{NsHeQOa&vVM`Saef3ETI-)~ELocLGoDI8e_-VT%ih^958^2!fNv*TSXCV{B z$o8KusS&7tc$u&GX2xeR7s>N(Bw32$%T2?XX}c2%_^<%aHgID`!oM@ zv=-aVd<4yMP;JS^PS}=cAqev-$x_RI6B1p@@kr&M_Z%g=3ko% z0;&9$h8(t1|3Lem^a?(seRVPsF||1->urwGDtV${9@-*q>I5a_u|asrZ>-bEe%ykX zaF)1X5uH(?LOKD5Wz-%Xuzw%l%QN>FL+KP(3e4YeH!LK{nw?Ok2|G8fI*r z_kWI_7JMXK&G7=O6uUTw5Ex%v$})YsnE>Mm+JrIQ6<-yVmX@*{s592o3>8iU2(n%d zA%`XledJ*+^y_LL!nsPA%5uUOzP41o(CU%AR>r@~$%rHsZHZrpfFG@mGqg91b^`7fHLS{Tnar`L7 ztl*SfxZgK7uObpCCoQL_F+Sxtrj~A=k&_S~@*Pu<(?CI#<8}f$pf$rsx)CeO%}Q}| zd=&wG4!co%mePw!zdH+LZ*+etY&y@Q*^&4hfQY8vVGI{ERZfcPEY+S zc%@l3-x}&)BfILzqg0Bht20UIqu+fowu@~Py>5o{&Bh- zQ!M5$kBfvFX|&IbyNBp)0H1|SrI0hwW4-W&l2YSj&GV4#!l~o_@e#aPk=^08)6;w$ zWsxaqkj*M9Qa6G|+km~$DsyFot$wJxEv6OJU6)3A3#Pf-azOm|r5ORYuBqv(iWLmM z-ulIYD1@a}fjurMji)EBw8SE>Gm%W1vkkmiqw7fct5=o57%dH~tNB0O=M+%`J9)QPKfx4S3SJj-FqRB514xB*Fq69^1 z!$UepNOB)h2-rjNP#jOz$^%)FxOi;l=3vG2+Fi73W$lVIM3CVyKT``nJ|XZw|NFRZ z%tpy85j!1yoqe}EJ3UkTx?{!n&OJ}=RMV*i(c2sf)>n*7G zJT+5IfxMEn#4Q@nV?-2FuKmk=I8<0{ufSdw#Ams@PfB2>7o;JHV0k)X;}HKrtg7zr zJ3&R;`8Xib9;N{vi~3F9>t+6?2X}l-j3MMb zq)f{yvfPC%QkqpLg7KKq^8Ox)dAUxU|Wi9_)7a!hpI2)BYl*4S{)>eje7l#DzxNe_wwz&@GE9CFkX z=gP{;VI{|b_*5jD&B&Lv*@j@2p(k>3jD70ZZ9NlZ_KqJFN&cNFT;1GpL8#VkHIgZ7 zCf30g?&W>ttWcI#MV1*Kp)B|7CgTLO!r_k0Eu&);^3N|EX5N!>nPvITWj4)mkY36- z8LsqxP(?2JX2b(W)h2XiKB|o5L`u^(ic4Y^t1ERE|CJ=vl8E2sld^JpsIv?S?SV}i zhY6n7?NNTtj)S~XLV(`Q_MVawNi#;X^`c1X6rn5nPnW!;#R{$Zf}9-uwudqoJdSv% z9^F>Pp+D*k|6m};<$q>~gn#}VTAE0#d%8TfANtdbOvqb>B8mA~XSXUB1XkfS8uOlb z$T8I?K7B#uUt4#*!}-&bi!5D&TznbIC|OFtli|6Y`T-W@$WbIkzt)aWty%}#_wgFE z6BbsJnuT+|T^#w4#eM0Wr27ijhUDe*!)~FvtZ%T|=>lX%hz0MwR7RsU;vA7kpHJ%j z-i=QBVx=dAm{Et@X!++=*}e@l*K9`Fyp+Smkf;!GAY@SKDcjYXKLF`QzNlrop|g&(Upm z0_8-BQ1|_@LcPcK-PH(^JXtO`?#`}4Q0O!+#qx>|LVC`g+T!rb?i=33p|J#QD!<`9 z>6>wgeXfNjvN%?8GCR!1Qw(|7rQy#sZr2K@v5iT6`HE12vbg%hGRSrxDC~Q#3~COV zndASE9&C*0RDL;baX%CEt%<10mM!)a%MdS;S219t^X$%Y-T`ibW9Tb!_2s6&bt6Px zT22n+jpuZY2;@BpoBNx7hEb}eipN*#y9@pzb`6Cd=YL5cs~*Qw8+3~=adk>sthM~5 z-**j;bhx>B@l{#Rica`BH+)V++u#ITaU17wnCXCo7?gX3tQ0ldDe``v;9+A^KhEH1xg zF~2|5mBiK#q}V^xyA_h+-!+|lzejHDG^Webrfwj2rfVgJOf2{+Ake-$#|Dy@u)Z-i|8;QVUD$ZT)0#jngfV`2KiF>K~B?wy>&O zFK8K;VV&Vms~Rnly`FxxBk#>%mbiJ*2%p;F#Sw&DS5B)F-FbR-yAGZ;Mxs&7@e?M% z=MCzUhz7^gC9Pu7?*63RYb6p04xvl9@GV9-TyGCYhHd{QTwN*dh3BRh5!~F>s8fpb ztGvsA4j+Gjvb3-w5tE)lrJkw%uM){m5i1ES+m*mjPF)jQYhCtgofnzEMr8nFN~?9F zMA85oSpY68Ptz#0YeV%>eV`{=J`3t;{kIU@6P(qeD=#}zCl*p&NOX`FAP!#@mzKb2 z0nzyRX{c@#v*>ayuX%eAsSp*+l5gwy-KYw8@l-hUBsyd1Lj2(G#rBV2(04qmB_!FDByY;&&20+&mQh0|@C$MTLd^dF} zrW9F6@u=fzX{sLrMRHOLTz%TN=Luk4C~xN>XV^KqrypA~6o@P$prt(kC4G%A5ed}` zZi-4o@q4gf8&g;f%_SwMfAO<@av8>W0Ok8kS zu9a#-`6RB)-7cSDs>4N+d8p1;_f1B^x}#8a50NtzG}0H(<1!1A2`f5aNCq}OU|pnW zb&?@-ln{&ebk%qi1aQP4*ThK7S00&V8c?peiWSQG zO#~bHKeaFoV`ep=*WDNU7^IE%Yre- z^VBoS`^fU&TC?u-8~bv<%gsny!a0k>SBTMWzqY;0_`G9@C+ zvDCn>Y(hX#HL8XZ_aj&8d>A8t?E?=MYFO=ZD;8!UKtwX(Pu ztnJ^uqFT)l_P&qKd$jaAofvfU0&gGl0v~hxJ#ho-w6V2GU~Nx&2#uk9my>RXUDtnK zfP(Ukw2D~z#jTG&Q)et1v|9Q^0 z`|k%$N>d^sWaqNqs5m>GuMPw#w+h`GQ5nS?{$N0A_*^=jC0wn;F;na;Iaj6qVK_GT z_dbJe^nX3xuai=6x3&u3*2!KpnPe=UiPOPfBNQPap)77k5RNF&kz9p&vo5K>lmMw* zOuTT(@G%hRms3Qik z8`~m^fp_pS;M-^F3iZkeXN&1Ax$k8xDZouMG&D8_#$4eO+Xv4) zlkndh7IOQ)@H6U#{pjftT3PcaR>&3%OiIEKP|RGoH5thpMtE-fjJ6K8Yx>7drHfAo zOmlqEMOsG=%q?t2Bhc<5v)vP$rX5rFlT5ziy*|J)0&MUQPC=$}nr-?jKNxqJkt?vl zOr}s~Z>+Aqg>wiMO?QCxxQ>Jh<$ro z+=uJHTd&;*mn;-TTw`jR@qNdT!7+cFnvqhnx!ITNFD#ML<&>_tAY21_ z7)&Y;i;dNcC1f$5Lzj}0`dymqUw&{4x#&Hlk}$eju+=IukYyyDEwxb8hfs{i-I9+B zI{eKusIf^63KH`TUptzw*uJ|!e|Sz|)IS<)sW7Z%^l~|i;jmu(wtOjc?MC}Oefy36 zyX|AQdze`Ew3D4Z*xud_P$ml@`|{fm<$zpsh}; z!*6YE{cJEB`#Y^(d2zYBcz$f(b1c%S90)T+kWSA0Z@ojYCk5lEKn@IYP=~Ig?_XTh zjHNT|&hndlhDH88JB$8-=s5H`%BAylX4vCoveohC9}oXZt740JuuMgryqw&?fOO&o z7~2rfgl*Jkz1^*M!`It2FK&@`g7xY%I(p)9%LLxFX^#8G>z7sjlGOqV+32=Bp+W{) zerf7>^Xj#XhFwq(w66pZG_Gaz%Hq227AY0`sEB>@?CrZ=Dr)4J;{{IJuKfN4?*ciw z9W((%2Hm&$}ty@(>2aQjc#mHlQmzxLAwi_ zYKdR8|BHSh!t8p-?H7sQG^f2WJZC-ErvYg~M)IIfU}scJ%xY`giZY`wW2tndd{krJ zaSo)%jL`Ln?$SAeZx(P~zj&14F-H=Ywf{RW|Dnte{5sj}x&Uku4i=Sik>G z&b!)ao#5>TTz7uf@Qi|rEY#3TU!_O!4^V32hMBy zHwi~lQgVIGQ7sq;?vr14H*cQarye7E5;kse35mY(@mZV?m~sU!+#@mz!W)|3M4*ReKh*A$|HA4jVmAa*z zLot3(Zf-YeT>d|6K&j`6_%urt%y$nSh33(C?Mg}e@5_}hP|hXyWRm|@4G{`C_IV6N zC%WD_ZcSHwbrViiAgsC8r4F~dVS9_|XEl5eWToS&OqG3Db&bs(pVeHnc)V-=9JrSm zRA<=LqzPCcyPYjpipB=zb`o&Ad;&~SWx5?Lr)eG!*OnZo%=B|T53a|D-Dr|A1oKxR z!J+-)C&iC<4~NDi+2ZT#>+0V}4A|lmtNKp`yw9gIILh*&as;NJ;k1{L)FD!$wI?_Rn)TL4B#J+0gb8geKy@yszs4g5o7D&vu(#dLNL2<&*= za>85Za=IkUq+9XS;c{9eI@%67hVVn*n+)bY?5gVXy3P@5BQ#FTmQH{9y-7To!TB>b zwpFm9L`^Cz$2`GVcV7dY^oqOOmbNw?LLFOW`pCn}eJDun6cp_;F<~{m;d}2V;grb_ zrtozMi}S{vvc-GLHz*j@cK?;pv}R>jT>3GU9Y_Hvt_ZK(#ukX_I&k= zJv4JY`a=||Zp`G}A6)-%=I@dllphFdymqMkz&zY9AunoIR2#(pQ|e#>&=J6nAbQg2 zc#+%Ry%YVs!PkjtkaYqr9j)!<3q@nl0JaNi+*H|c40cVpSjzM9S!7&VQdlVQQHixA zyw3jEygyi>Sb+F625wrgFf$FH;A=|Lch^$dTtkvQi^3%hcJgcV|DK+j%e7furBt^M z3CBo*%UZf(TU%Ku66MCv*cuud=CV1nv)LXsmnx>!tT+e=2y4=Tt)o>ToLBM9XtN%1 zQ2yrk>3|V^^V6k6|6gvzf&73c(R50c1szBaHj4hypGJ_YjhMAN=R~9f*?|*PYC&8> z<1YZVkzj{jUSHB*LwMivAPJR9CNs0IMn4$&B0EUiZbIWC<)HtQ%~X#tmdgYCT9$NT z=$w*NUR13b)`}TTeT{!-v1dVZ#GFc(ApzV;j}B5R8DfROg5exKWS*325lBUJ*}q zVh4PoSYVkr^vj3m^I7-p`xpWNE4j7`#~lfeB1QWoMtyiUH#aE-g@xlwU>?+2&0Co; zz70!gVj#m*77yUls~oYW1;q2eySqha_Mz~5{$xXU8Cuj23<~JlL+@ z5PEg4We$yAmz7BgY^)tER3mY5aRCb58v@|4o*ThJT@hHq%I0a(5U|`T*8+@HBB{xx(KSBef zcZ9q}wZZK10Mb@2`DREfWBaolPQ;2VC=eb-T@BSixmQ>ufIyyfl9)-+M>rIUib~SM z!|Tvf{^|KisZxW%7h`xxKJ)q)A)nhekT!aFc@-%4!~Ajqf8O5Sfv_V)u5m@T!V`g> zNP5Wx1lDn6fjphUO>=c+MP?D}Ev>B7c&)YvDFUM8o>r$R?o<{W$NmJlr03nk!|#O! ztev5#p$3W`lNS_cXJ zri|NZ7iW8C$JpK?6c{t3F-_IlH9x&O)s`3366B1}L`5I=&mvRw+N$rIi|ttB_hW~m z@wPWPtm6_A3NmG!h_+^KesX@F=}fQ5U;5>kC>Rg~3^i-=l2X0J8Q{C_P~`dXc~KV~9>1xYamzc5p?Xjv=(0oeTX)xU`b-~EeZf+!OJMx?`~d;!3uku1n+ za&*(OKg04EA$ssJbr$C{fDaeQn?nG zXwiT|4qe;%z)MP27)9|$ru+vvs*fenMAy~T(Pi^a4gwyUz47GX^gnEfp8)`_Nq5M; z?k@MnCl=^d{%-(|Z9%{EV%GkDhZ~i*MT2v5YrDR2l6Im9uDsAqS?I7%Jtu%P2{#by}S+&u(6@56~UllrCI}hUtpHHr+y}; zEz%&Rr(eSvt7@aRJGFI1G05bH1LiyD5Qahr?CSQaeaZGXlb~pGg*ss8DrG*ty8H3+ z#wb7leqvq!G;BW>$c}MDf7I48z38=#;m|JI*w|j<&e6{a;p5|**v!z_9Rp;*plrNe zaTBo5zfENp7hfk%TPgX$n)Qz8{7MdN<1J$^Eh2wF_k3_v1}`+T!65k%{Ew(%f*_e8 zS5Q!ZM&#GbSwfB3Bv>ow1t&ju&>`&GUE6&V0zmr1(?3T!Dc_e1c(9nXLkRiY@k|=8 zuCE77VsFId`Tc=NR$;*UC-7VVMRm&;uA6=^!eP4 z_p8nv1nJoZc84%%6)I8QcFFHbJ%TELZRsgii1{oO`p6Y@etk!^b$A#C*jEZIVnfmJ z@gvXnePTn~U4`|0_)Gi*kg%By27bIe|EZW4a8S9*;d3YIdFlRCsZlKveKbE~C{L$b z(qvFOnb~5rdUNu9^Hdl@qib~Y@2t-L^?jG1E%EolVPZk2kA@2A6~lXKX^?nZ-Bvq7 z{*6}_Ur6WD61RZi{pDUwP()lDD!<@gmM*`P6fADfON*|{y|=>ZA9cC84n+j5o?M_^xC zfB+l2R&+23oATS<*u2+T4ZS=WbbO!x?zyppo7)0O@8BTre1(SkE#p#?Cqlc>9FmDd zbWmz4RiD~VD>Jj=QZ7r76cwnfjM_wcS-o6E8@t=!XnYp{uVo|DfMwtfXVkT{^eJG< z5uL0`Vs_3e^%&?s@0@RudHnsk;Gld=wpAi~pm2EM;9gWdEot=)2rFuLaKDu? zk#mS%laMh!m+X$y*Lt4+AxUD=M4q2_QdM_|zx|Sf!=tyzU1iuKY?v#=6|7C%HuTy! z9VJ<=LJ}4gCHC!G0kSSgj5PIVz2+s&z{fZM-j72~i#x{opkJv=M_o<**xcSe7U0gd zM;%RO%8Wk%61eo@k2hjbK9ju{4U*GvlT1QN3-^ZNZ+7Uf?W?W@I$Z7>Hr``se;C{% zle{>IN~cxA@S}Q>7*}v^Xs#D=acN?4ss6lPT2Mk;)v%zF#%sCp-MH!dg^JJl*09I7 zIh-gw0R<|la^<)_A!_EYR7y0=J-J-OWVtwj!hxY%zkdcExjtWS^aLT&{N6i+ZF_Ur zA|u}nf$|Y_v~R+wBz9sk{g2G%Yc;3SDn93Uy~Ggm&2&jJAri1-pkXuNsj?>1sKVgl z;sSw`W+(<9$gGG4Rd;fKR%T|x)16lQ5sW=B%nHlzm#i?m9s+#MeSn$aFiyEufsjyS zmm1^#TS%zkZy<=d5@XCh?DJa{=0PkMYZ#v9uFUWe@mTeGYJtoHSM?|SQ7o-k*TSf3 z>G@g^_Qhq zN@sKyZN{gxr@oTxjc0o_pMEB!b;^kq5gD5Nw=Tm5m@Hlbv%9iulH-_3{>?ieLA0!} zP3@n4_Xc`(PB6*CC&-K^GgKlRAI#-+5IlcrO*QmFQ?JyRzlzu`GHsU3)y^cDmsDBm zXlpZ|8KL+sl^9%JPElG{$4n^pa03peG4u-o<|=KzI3SQ>QIT`w82Upf407)Jk_*{1 zYF~7_G|J#n1S|6FVz4GN!5OJ1d~Sy;IOWgG)%H_`obWoiGMIEnw~VV_Tf`MgAeT3n z3${@z-rL~4aMR|sfuN6SHG0~wGtzZwLiWg5jA@}$*}UM}!@WH-`e1;88^xo4xIPLm z%_Z<2hJ{xy5EY*y6B1ic$Xu)xxxc*IzEWh~i=6)RS}!@%B)vl?$s;991^qn%9tQ3L z&Pz|Dr3EyF5pgSc_X*w5PkhtBe~01UX0G>DU-X^%ip$gW71J-DdsYDb7C+K<-alCX z@&U7My#6)$x6z0Vr1;+#s%SQRb~s#)mfizK=%r4xNCW@n$J7jXFv0caimO+850j3M zneQH_^MZ{)H?wK_ef$f0&(lN4M#!5-{LS49AnJ>qSRwEiQVx>;@aM&Sv1>*9(pFI# z8zhqFs59&181%cXQ=_Bc7!-^Gv0!*GlW{x5o4v8W+@hZSBV-s>#&_3c*OHf^k4S#ih_B35Xo_P%EuzU|J1Yi_42*#`#Q*VS=M z8P{ZSeXY!<_kb_3&*THl)`|PZ>~yjq;@(*5EFpew<~h^@KS&W42Hg1Ny@(WB=*M_~ zRRaLK;caxBITA@WgZwdFYhO*|FAs1+_wBXA;%%FyQcupyhXgW*A;I)z)2s2?9d4VX*`MZG<|y>|vi6WcWaYn#CXH7XnyC{{Qo zq)e~vGe8yP^$)z+Km%bQkjsN-X5xH{X*}7nsX67@-dg28g(t- z^BcX(a-e)_*o>CJ|*Vj7_2bE_YiDdI=p0Cj9)1Nxem;NV=yrB5AlLO; zZC1_5pFrTt;DcOIE1immceOCVwLSM(GbgGMl#?5-ldc^sWznQkd zv!#%mWI=M@$D4&_ZXm@j7MyEIftS+fb)O^I0~lyvNmUxaprIubP@L{+QkES1rYYD^ zc)zeJ^bJhLb=iP*9gnWd`7;^Cl$7FVznIjzbaoP5u3a5WZ#q~_9)DC*346q!#OFzU z9w6c(IFtXBEOXnH1%XlyRN{08kW5dXb^bnSnt4_lwUy91whg8mD5J_79aG z^HU9Sd?;MUS$C=Ncw%7zP*F*qRa!9MHaAh7Si++)%@AClwMo+Rm86Kd6Y%=%wsEN< zQRb6a)A-`5_9#3}kbFCRG_xg%L^TvVLA@u}?^sVP;GUDi6Me44iJ>cXjiDF0I( z`d{=8^}j(D`{Hk&mvq2sw4NUSY1#tGlO0!jg7sLvX0$7JW z11POc3$WOj;lwuo{cAKpL5?rFe*gym9Ifq)`u*eGQ>z={%k8Me5XPa%>nPPQDGdvp zea|D2LJq%~+98*{jF=?V;#lN>2=h`RADwSIA{!LOAQQk~ zD8$6}CUPaaNf`e&TZx@}Uf&PL5OclV04A{latpWfeiV?;tQx2~f! zOR+uLWC*uqLYa192<>+U^lpR&+G1oGnKX7kfWAik&Ca^qYl+(He9fO!4=>I|Kk*TV=rmg$ zF<&iizLx#?96bCW5YcO8P!6};Wr5P!)p@B`136*@GLZlnr`%|c zM2I?;!cdrMZEI3kn(pFXmWhmCV^^c~G7SF_8fLFU4}7Sd@52Mw2@GI~NN!o_cTUtv z&dLG;Ao4Cy(HcLkHea3}>ghG1hUgM0k8}|3QFqjg$~3Ag;1LwdZ+DXpj-v@!5P{O7 zARuzUi&;Rr5K#^{r_%sMeB>$O-(jqP_w7-aGoYTU)u>5_B9x0S^}PP5;fVW9BL4-% zPdZ5s#KNU!ZxmocAP5;6HmCNW2ZkUHzvbhAPBbr1X}j9g=42YmZx}HCTUd0a@s^A` zqnJqoa83X&4@GiHO5>F#Ft^k7_n+gFE3YJ6K(4;Mip%D@#l*qz-{yut%}9%6^z?f6 zu=tR675vMY;asC_*+gcSJ1M!ebJD47J`{g{p^q>y`ww0R)1mpEjV0}(M;&MWKO7YW zs?W@RITlJRKhyyfPr7P6kZs|U_>$}C>;LKWR9)v$h2IBYdHL^plnXdGYSj{Uyt&%i z+mZf$CZkq(%w$@PxoIJ<>oi8W!72TY_`Lnw^YcF(xRec5SH{LeN|a1YO#Ce0e${xs zl@(|K?gt{nKTfj2L5Gg58CAJ}B}GN;uZ)z`-0_!529PPJS=K^pRqHs9Omb$oBm&-8 zC6JU*c#k>}aA0ihg)<$-)GH{&oR92y0_%{gaztdXS2T3az%z~UYbJ+n0YV=jl$M{P z#sFA-r*nlY^@R`!Ds#SMrhYX4ieW@fcXoE7DD){LHAXHk(Xp{(cZL-Y1GHmWm`~cU zoAlCOH4=4Ftkf$Hhc91>bBBTsa(e%z&${}@z!H5&Qp9w)Bs->Em3=6v(s^#}uGWKK zr_k%i49cc}Oa9|s$sZ93juZS0(j@)z<*Vks>|{V(pYP)7di$4D@TKCLrQkXK zkvmqiu|s2JT5_D0mX@&SNYT4sgrTK13Q9_&qs7g`uGa|6u|oH=94{Z?@h+G91R^Tv zo5}0Uk;sqm;6s{c?%ijZM(Nwo_5$%Mb8goo_7G(J^4QNbhfjEbJ~o@pQ5-uAz;))m z%5qIRA;u@L3Yb5Qd8J3b7ejW z7^|eUKEWYN0Gb5k5(oqicjsuG7YlrMR!3Z3MA|~->2dusd-O%FM+*yEv}9yg;Q&j2h#u~$)+_#CLQX+p3{bFDfKpYUyWf&qvYJ(d_2sHg`sxOq`gQ{n7rssU$?yVPSS^c4Ae^*? zP_0C-IP2SmUjh`i90H^ur~PsSK=$LAT&*46^) zOyN;m;jo}3BIXobqJcFTl$1#`g!uXSjcjeJ71edpR_jfM z(Q@P7Rr)Akup0eEZ8}>L-}HO9*DNY3dRyyyxb9s)0d4TmPLtG%cBQGCIGfLnIRxJR z_EaE^)1-tYsg)!UHedE2Caq@w8PX~~v~)b20i?|xx`5ZRz$|kAP4;m6w`LhNIR%(E zVg**PSy))ouAceHsY-EFFM%(!$;is~12&8cz?^{X@%U{_O|R2q#$!Jk&~jOF^ z|7{e}n;dcHxdqi^y||FZH|u@Y9bH@M2j_mf?eFIf#QhXtd*>J6KbOiiXa@^Y!C~~z z#n>#fni&BFsZm|dNo9pXy9u@0nLGm~&qQyAwMGGC1O!nXuXZALvLYpa^QXl2>A_Iq zXB5$p=-)t{&CJf$DNv%#C7?J>gi`2S8Uy4iR3XUzPzZic(!SdK*X$&QX@In70&Xz# zdU%3HnE9nF3VEv0YBXm6Qk_2q+H59vc?#Jp)R>>6fMQuPR@SjMQHf%7Oue!m;Fj7) z4IZG(k+4d>Zk?Tl_DjY9p|%lTUshQq=}uH;R^5Xcl0EWpXTG+2M5M1;Z?o+5^gP|e z?R`Vgo$-GV_tsHW#@)WJibzSfGzbXNNSB0!NOuS#0@BhAf}~P{gh)zvNtc8mAl(hp zASESz=JI{tyZ705?{W6M=ZJto1yWj`fuSqCI!!Gn4Vd;QMYF4u5(*>ouB)~K z!rafGOiyZLgzm`v&E72QOC*t z>WCoU!+g!-uif1kSB1u`$hZSZEd(qt%m)8C=4lIfD;b2nIMmnFR)2y~a`m-YpDH^m z>yE=*d~h%Z1fC=^-x7v$_E(_!UcV026z@N26r4r8* zbaF8}T0PYF2@!~x%@VUrRa;y8v#Cmoc6GQa@nuWmInRX7&o^ydJX3MNI05qvh@q0d zyB=}ay}#EJ!E7L1 z`p}{s@3sFQSAIc$uoSAew2%v#ekcn2&RpJVD$Ja>OWQ*Ut}Fx+p;qntI_Blm{W%J% zd;b4wBD{!HRfqmDZI^X1U@5@i@yUD~^9lkKRui=fJ`blVtvhmu%F1 z&e@qHJ}e#g7ozdnDi>?pvw+qY~qSW1fn;_hDxl@!fzl2phEOl z?DBvciYn7krsM=3+b*mGc)WDn!{s4#p0u9T3tla>nwK|yY+aHn|( zW8(uf$)JcAsqlamy79lZfFQgdHMatjl9D*#MWm2BcW7h3!580x9`VQ3gABSjsShdh zsnk!MiCn$lTIc+GK@nkvB{U<#3Rfg;H)iGMC$U58Y>zwv`^rKl zaY_&vkvLa$iP@x{wYDp`R(tRz3Oe0EJn^2?xPg{H0$ur+m$T&Xd%^uE1r0Q>^<=1< z*pCiiv=}QQe)X=-W+DJUaYC!N#6!r-%lpdSe(<+^3H(oN-92Nnag1HxrAZneq{LBrN znp=?f4;pT-W*uG}r-9j|*7SLMsLo%p6IoE%liOPGb9y>1Ie|aQ@Jv|+ zto9qDxrLT&@OV zT2DC}|I!e0{?+!8I}P+LX^3BkmBQ7guKi6^9SqobNVl-sOK{UmOg4GPr=}1Qj#?in zu&016I>=@2>e^zmyDped7Nx`HH-0{6p;1#-PHj|@lM{D*66&28ZC?K%27btaL#CrN zS}?z$p@p254ojn=$jGSYs*U!*CU#to`G}o&*~fx;_RelF2EyW22t}} zgU9iDB!~O3te3~p$35-cR{baLZa;Yx$OjFd_fGHF?fsR)tXzXLdsB5ZPGmxY#ce`G zpjxp#t<4bf*q*AQI@}l;S^b8p(rmGqr7+eY3~#2;$HK4}QI!`4+wlqL4w2p@_BnZp z-}c)=@Cy*!tFF(f3F!Zb(0q9A6QhbaY`Fi^>F9ssiv$y-JW2oR2l%vgxu)RCTKXxjmo|ILPA1tl+saiJT&}Mmh)luXCkKW%qj_wIVZfu+)% z@q-2J0((L-3O=|Wet)~4fBo@U7K_RHU&a3n`5X?hy6x7jAg6QQ`(k!z#*00PQ%1Tk^A0Uw#M~( zxHD1kV7s38M>QaI4xY6~4dW6X7@VEZW=7aifKS#dt=}j@Dpg9HWSb{0Q1(tru=~?Au@6X(Zsasco~d>hT6OHBrHV8uT{> zWSg|?Q}EE!Qy7q462!q{_h&}$goslH=l_ZzP+}a{4-dNX>v=WhN&fx&>&5r4v}M}K z9Oh1L+_<3(acYnxNsVpTPi~X(=3t8*9z#nd1J^szI`Ml-DimyaAK}S8^MjoeJm+M= zTV!!JGXi(6J@2Js6~wa%uguY4xH(c^U%J)QWcRC0y!xy*ZMGq7K`Wg_^+F$K-QFvp zNxj@NtE=x|&`N5I(R?Gg zcr9pm`|#EJC3CQax+eC!7q-N0!&tsEHTSB2)zqm zw;0Z|WvO_ks@=70mYF4yfczvQMSO_iV}QAY#Ai<4=!5$Jw<1~jk(`$An@O5!J;mFu z{tS`T>(#SO^{PYJ>+GNcQcYvgq~3$~)cZ@-oPPiqaL#|_0iwuMj!J`|#b`lV%31G} zZQilkw|KIbXAj9d&lk>IpD(>K4rP{PW!?S7z38<6BXNQoA#Q+pG&s~1!}?B+U1@k| z=}^pdheBlAz0)~TWIAWdjUWWL5+8RO|s4E)_~A={0{JIK~lxwqm> zzB_Zh@8D9*%FN7M=wE2H5WoWM;WMX9Gu|5!+>w&ogVtPCzH+rmJfBVK3wc<$xN!7D zNa(ghHZ6jbgub!oe=-)BD?i=;DYw zmhDo3F9`v6>SI(u*8J1}j}=aT--cNr&g@$s}p3amyF=uf!{@ z>@dF#a6QW2ljr=;u_BrA>D zcEzPMPY9Hke>ukl$ivw#go#>b>eiPVxYp@K!g8w1OG|yhwF`MKjKh&R8%4Z(ftZy3 zU=zrh_g+O_g%g$b^Yh5a4}l6~&eY7zcl0W)A2rDY($2IXiLvQLj9Bu`_m@hJS6h!@*dAL z{v2^iGhO8)26m-Gej2fYo%+Kpw(})F4jA#TPllGJ%RFUzDG4fxJRz4V&i8Y>y}eqs zC49QTO$I{J^GWz6>lZ1>PnL5z{;m0TV-- zWRNrX%*u-%nUB`za`HJ+;mBiPAciQXd%4+_ZsZ1DTbRN;mpX?RAp;khPo@kSjl)LH z?`su~cE{q+%f9vkKN(P@@QM=nEZ?4Bc(<)_Dm}mQMOqs!MFQuzrc$3J_yxCvb;_Gs z!A-b1FMPOZi-1shg7p!M)xq8@ReW>qG8a9MjE1s$PPr+09ZOEPXWnIY$8MgO{zxnY z&Jkz21Ey%JdjE&RIMe*J))eI(vvPDoMfXXBhb63PcC`2krXL$)(OVMgc;DyN-im1< z4C4(RN8gE9p7=^61Y%POytOs^p}O3eUuy>w~n&vp~!{Yp=?|1sPVu)p-C3s-H`_YOc=@v_mP+?q=mg(s_&x z;wqv$8l|C=oQTIzEz1?x=e53u4ibwj%Y>Z&6%I_#M!ON|+J&D#3(j25>rBJgls89n z(`g42?l*7UT>Ij^H%Kp1Z?omz;E5eGn^_zX*i zG=rBJdOER~wrf%jehg>R?$#Y5stSFB7ZjP;Ef9xw@?}Qesv0ue;O^tG9K5MlX(b7o zBKobcgL6EhrE)=!qePq9_Y&vdLZT7f7&$Oapm7^a@v4C?;9SDHz_hnZ|A0qjz!h<9mx6drB7iWmw6`g_hz=XY>`McvfJ?F=Q{6Ta@N^-HkL* zbak!1v?I%zQN?3c@}2ZvmO_f1j6q=? z6bgOAk4a`%bv*wZnXO%ozxs0;l=F$*Vx%ZAQuQ)tE{ER0!xH@#rUx#C;E_zEA^mWX zCPvuCn16HwANG`us>x;a_#?sU-g*;zP6-nE|D z^>X;o-9^p!BSJ$57ms$tQYmHfl;`~hGmD54;$~-nLEy?){cIr&YC364eOi~rmhhAO_@=i=crq#omO8|_?~(> zT{IsC{A;^+d#*#$r0A1KhTOxv3lp-gRm-S^1Q^t5OG^jVKAj;dE5yggcPdmA-%OxF zvjQ5e%D#Z{_VNC~s)=s$11A&uIr>35RW6hH82AFPkyD~Sml z{WYxxy=^XJ_<7ldO*h^^o;PA~eJmBi$fUmg4ndKLo!eI2ebL9iALi z6w?Gq8%>Tp>E=&8x}}wSjiyRWt4P)3vwW%QjC?bz$3 z*du*jGPBa0ntNMRW4tI6Ij2a8&;?%cg3%H~Q)0 zg*m8cpFa`35UBEdF!OP#s>tnq2~!0{(`6;P35&ydeETQ#KZ+?^;)fakE0P>^+eFf1Wbg+PeoR zwt|Ar_-nF3hA%rlK32;hV6z}2P_EMIW%!PZzE$F>esoz6drF-nEko_lf0%;{s!K6+ zv~-T3$(5RyJXVm=)n{oL#*WX<#=krl%5bVH>R@;;G^52eGKbUeuU>P97AXIL{-w0u{Wb+?Qb7HZWN?n8;0 zv;<|?=RV_7t*yeHiMp|PZriII6Is0q7D+`xo_{hAYOB0J$EFm)J|`PZ_cB9AGTtIO<4tb5yKR;-y@Kj*H)P~n8<)2R z#nn#xmvEhmXm}{>9Xr1iXRn#E7;Q+g@Mzj^4rRw};~X7}T@hJ4U0lRJI8L*gD64gN zRhYnYIH5A6(SLe*Mpee62cKGvJ^h6zn*X4=&grSF4=&n$9l4bEKrC82f!Zr~u6#Hg zIo|_+h?gmSwhz$I9?P;MpQB_ivHqznASMlsv$Mgf9**Wjxx2e|0nyp4e)ms*M)+KQ z9Y>S2JNPB{C$98i=A>_qdj12pwt%1SYScJS_9Ij(jRekXtO>mh>qunSs9Gs`cETd5 z#*{?QZm=sPB(54H6C9=FxhbXyq#qA1KZ&-lo_5#B@jm(%*RFpOZQ^r!66a1jXjcw; zWi5WrOe<~s>YP{ZY-|ZoV!hevo0_k%5EH%SRoEbox^FtUwwuBhQvZS6|u z%F}v0yf%L;m^Z%K$oU{j&Xw^67`By+ibb{1Rv7HR zy!E_VN~E(Vamr^2vcXr`Gg+8l`%pNH3Te$ma#cHwkDCd*`&w+@zU{B7Xk3g>2xijo zGU1_Mrznd_q~ns2VqFdP?jAV!hBaU$TK&TH61}0aXZK23tYkD+GdLvt+eAOA_LOd} zL?;z7Ht{sgj;UOb@H&gj&$h4tlJ|35v#!=1SMuVy361yE?vt3G zd>Q!^O)$=fe^1JwJJP~Npz2X?tE>ZE(B5(-3KPw|DH7C(uh!L>DY)}BJB4ZD)Pv+@ z9+}_x&Z->jR~hlkYZa`jSnpykiOYf@U-c+mD(Ed?6N^M-6V>2gbcxY^6Ka#^9jxtG z)O(B9iP`nEg-mij1=Q-a^z>mCHQL8RR$#hU+oF@6 zu{BCDOg@&i+nK~%CGsmcz-@Pjhx#@)E+EbWlHpo98T@6#PG^tnW|GUumdH%yKYj#0 zpWuy(yjBI|;8r8O{t^vLDJdE7^l@nGWl^^>P&)UHzOvtc6BfW4_4&Ec&I@caVn+Si zS7@>8n*23lcyZy{beF#mk11d(T>JE~#J)@jHiF;cf^_ts1*ZR|q2Hw^w!x?}7`=m> z_(}Z;#G$1gJ!5D`fgv*mQQ{cPHqIJEP?jzkm`iocTq{)<%$BA2BiGC(ggUQ#_8$9T5{bwGxnh`T57mB)_Sp2VyEW>gyP< z^&+Gv54{;AB_$;=k_^oBAW&Pat_8>_D0FNz6C_+ssj1ZU2S4|B_x5<(^*37rP!Z1# zY?qxtwQNP>gK@?%CO#$f!$vRnbd~f#gboHe35@$geJ(GE=lagBgId#`h-?X>nERot zb^|fh_~hiqDkYc(#C{mQ22&fEg9(x7PU8gwm>%C;dHp6GJ+B&#w~x7*L(CxjPCkx( zF@V@m;>C*(ob)b8r^k{?hm&yVw#u_BNxQo8>prjzOp`=u?U>TDZpDayaKe))?AGPl zzC}!e^V-P5i`QyAc=`V2gZ9}(-ohOjFfjZYuTjGm5)gPk{2lkZa>`ge+V)XLK z*SKd$Y1_D$fQ1ASBJ<6=)ZizZoi$+oD(nwqHZq45C5e`O=4+u8B9DVILPKwB#DB}( zivUFzs+ zua!JVy)xca^#BWi)wiGY-NPtUd85eM8HL|og?c>$1GppkTCEG|GENJYSY=N<&VKcXsB-UJ%3KXq={=Qy|LLj6(N|66 zp9vccfDGEQ@z|G9EY4{-MG!7l!nF)NT1ha#VNLqxmMCP~dJ&-#3+xJByl6bAkhz{l zeuBS5P7TvFY?Y-3FEwh_J`?H_kM=S2potLD+dsok#p z;zb`SuWR5^tv@-ctCjvNeYmkU*F@D7%M|uuSlw#&3-Z2qJhlgl2O@f5AY(olc`cS0 zjR)IlGw+kwi-ruQ4E>riS&i=KJ8yR}h&c{7&1-eqGQh9AHnxKc-g1SpJcPOm5zDv6 z9TA(2_uq^h#jcQnNcBLgo7i&nJP45xk-UW|Rbiol$Z3P}TbsPH@zZZ0J*B@#cHdh& zb?%cKfRl%R-CsR^_ zdz|?1q>2nC$APygQ5hM{G_IwLfaUoD)1EmKU4SVvqe*)75=%f1zjMmA$=3sR0pxG9 z8`NiDOcSU0V1a#S7&)fy6B$r^N@sFRIv-o0JmJ9-x0rQ5+yI_9a;cr&X$5@=Vo3w^ zBrrK^$ua3(c{!fr%eG|v*?9if(g)iVd>VyoWd^Fd-x%*wi_q!u)=NWhS^f2=93e$% zAhNNE8U*>9C3`;(LLzp`S56Z~L_-UAnhdRX?(quq~wP(T@Mm)in2$!DRz7gI1N1ZtrJB&6j1`~O}f|I0Nx&> z&hfZaXs0paWBKY=KuI;YJ9ZS!clJi(C@bFMWp%t82O z-DQH1PV~Wrr=*k*QH7t2_`3jfUSH>$YqhA8-IrG!JRqd>zRtG69s62b%8~E>QJMt$ zjNhkUbLu%gOi zl*;QIjiK>@T+@m_8vBMWb*}h(#m@|bp0pH?WJEb{z0+7=-Bf{WdVyY@0eh=t0~MOM z#Es~KfQfpOZk{Q1i|{D*d(p>q8}ikvDIqs+FfCr-6)XlW}*l4yR3Koq#>Nt=7Ivm_I zvBktW0WdTwfZ0Hm|5Nz)$Vt?NRPV2u4vD{>c1P5ZE0n!z7XF7@K`k70clV%wKS7q& zH&TM5iR$VxA$NXjM4pJPT8ck#v^N!#R8!H>7*u!V4?S(}{)s=*%~bBKMY$hPc{3$) z{+Z{+NSu38zDD8mC&+jj9Mu|2Ub2Lj;MtQHV@oo!HZE+9GK;mQH|J zEPW?;^V(tAUs{zmWV~ zMNkNZx3%5X*4ABXd3`b6GLaAzBzt1a$+ zMJjY+?K+IyrPjE}5=Fr)d%+$@6?_uTs96MzVr{MAw98I|R`@Q*`qg-P zj!t=?sQTfK&($}RiXaw`%ii8zRF!Vr!HK;ZH1@UhM@u>QX!izh((v5FdvMoZ)*+-I zF88XXX9HIF%+#p$o^d*k%X)ozdoyv9X37+Xnu7Npyi_(kzOR!%D#kbXi+XBcru8Xy z5CSG;jYMTgCBl-?-(4~6xC(fT|)|_x}w#%JXfZ*$B?Ip++AKx+31A;_Hh5AN%7?e z=K2ioTyi;@9=cp6CfA*rs=Ym#oqU|(8CPPy-sWbS@|3_wrmJIro%dswh$%_5zds8a zy<3vY;`9keM(!~o0ZwYrGAfR_#QUt90tH%v>dx!aawri_n`4`9{)yR=36x6?F&^vv z%u~7v980pKXz%dwGNffzdU8vOqlt$s|D2qyJ4N=p?uf94<&H%~-ocqKK6?QL;?`qF zy1ljn2b`UEVQ+a{eh_Xe&+n;|Ig*ld{PX#RoSkgfcy%FwLq zruEa8-WNR&W)@V!ubGc+i;qtU0mGm0bQ(t@LCnDIkcUOZqfAwgwt_N-S!1~8`ruu< zRr$zr*@RIe&~s{WiU7f}qH-ga$hBk(>2z0R{S$T2&||X}c*d>ySn)2@GeOfz|8M)l4LT%sH*+$He z)0Z7UnWAB2$jENQ?0I?E*Oa4~`PKks{ZGyFW#)^9uCPQueZPff$)HehExHubiYW!u zd|wDxr6xFfFr}(3?+`@J7O%zq`hnOB%A$m;k4z?w^Qq6k3wr}$Zp~8oL@kZ%2eSu# z6w=(J`13CLyl0O`WejBtLxel#{!Tatkjo!<)$g6=*EadBtIrPVbAnyLQ^-rYDEp`p z*ZyU$6&+KmtyDKr&mL&bwM^?L#0)DQcNG=`F$Z$hP<|v{ByIoE#mOba%i34?a1#mT zASkF#NHm3OxvPd9&{^vAm%@Yr^g+o6eNI9d)NvMv=ck0SVN&A065l-s$?1BCY#a8I zi`*i!-qT3lTA!z-Amy{2$g|{n5@g%uu=L_dmdM2hPu`}8#RPubH5!>x;qzB6R?}ZP zt>6XD8U`SbnxQ?2a<>Ya|H)FzIGfX4t?Y2lmENDLtJz9j6{cH)kU6DY4$sZKXOkff zUSxSHR$Anm;V0h%IxEEJeeq1E!q-kiFmqXY+4B#|KQfa+&}qaB#}-rI`>COlNs+g}^K z&fW=Y6Cl116B!ApXeyUcQBjUMZV);Ar=K;Wb5TOd|F8G;Bm}I~CT4b8chdl>B72JP zVEM^?)WJKYDhs*`)}a3fNHu8CXE>k~p7v0Z2Zjfkaanm;SxZCVwl89@2rRSQe(}hD z3vfwfMvLtV?aB)PP(@_ebM$jRvz0)(RbsrV$YM}SU}v}2u3zJz)<+>?aTlVg0Z;;S zx6i_2zOHnO7-rxhg@8}Bria_+$33sBPBX(b4~NK>2Jws2>Z?XgFcA(h%F4;fH6s`k zb?(+~eF4i$OTmg1f~ksIlK{?oC@p;p`fh)qdoJwwT#mFd@Z@PprWlCqb=x)>1H!L; zJpX{#=lM-6O2NcQU(llWYOPZcCqOR3?6^^&$BkLZ6E*$NGb=d@w2cfG0GV zx!&d~SDv2bFLSl)snJS}PTAADFAuk7{#qH=d{Ta%gs?Bx)y(B9N0QwG% z9GxF4V5sBbi-*q)yc=9R@=#X2%Addjf#7NLZ$h7+pwF~VIIM=&*id1+xGbv7qI384 zSY)6d!Fji#>rYzV*lHd_4V%$Q9L8+w&n|=hJnzy1jvL%FpKeAJB?-}hi4;=m^w4g< zhR@VLH}&^^ag$MVONvUVgqv{xskcZ|N(vQ3XftnooAr!ql1IkUBO@CwFz7+x=3JP8 zLO<2nZmf_%@_*UfQPalU0#tLuUJjRB|Eb~oaV)vC;B4`Nsx{rqBfVj=9L{J4KzWUf zeXi2-7!SL{GaTWv)$8dEHds!_ernjJg@(wg!VCc+q$V?zDcTNY@jwPv{k1>@XS(!f z2(yF#{oxtP)aOR#VV7KqX-5%=`wk>Zw!=r!OrN^PEPkHihdjKxr^6k2F}u^J#J z$S7Lzdv_4JIkqYrHGiLnC{8c;r;pMQH>OVLz{POMtNuCbHlr=Pbh zgGGhTD8uN<=R$d@y~ijaA<_Ru&iEIvgw&`quuAbAHwQ^!eP44Gx7mWHHp#Z-2)gFz zD8#9&x6^l(tJwHo?NS3AJOI`+eVv=bmWiNfh0KdnQ+W6L_wnbapPuIGr<51+uCGcE%Pr~dpCXii)(pC z$n)M~v9)Cz$YJo!-TZS#Tx}q5Ce1G-081jo`{Jn9Ihc6p;gf031J)jqmc9 z0shDkaLUNa%Il9gj-yvahP6@m8-zcK@$zbpsqzce<#4R#AVt+@r!$|Q7mdyhyJ@mH zu+#L9e0zkA53!hBDGF8QfZ=I3v9<8vA=4kjTNhkBn6N-37*l zNE}SxhP@l<5g(k6qWZ`da2cXD5phGn>9ELWN(0d+x0&+(5JVbHqhMLG!&&(`?xYN?KY|Q$SyGQ55rSko>^R`X;gw7>gD^J(~ z6KqmTZWnck)1_CKde|j5TlWA(PAs0NC*h89F_$$r8YS4J?y2R$AXV+zvs8mX@WgoI z4d!YHMy(JjXGDY?q>oqGHrd>WvSzEGDq_>Juqn!I3dB#W;ed*TWM)S)vBlK8&C=Gos*i@nZ{ zH&4o<+&sPN0TzURaR*3dgN%)NIV*9!(e`&cR|@qG&)!QaIiad;(#gE|zK{kRpuYM5 zza{|$c;aE+{On3Kq?rkIzt96Uw_RUe_p|OY;>#$v4yPB~+#m zO{WVP(LBWYH*8*;`%>27J2d47i^Yw}&daFriWHNZUw2PFKB4VW~d?s15GS|Lzup|9*l0jaR+8t}YEi zr(lTnLsY@RZ@Dp0WU_BBmJpLF z=Wn+O;4svx`CkBhfxpgqR=0DodGKq7*r!N8vn7Bq;eq*0LqkImrJ34_g_V`S-Q8E9 ztE2%Z(48_GMt{bKhCb`N)V`8_{tw0+w%S7tBRg-Y;7_@Dj*q9yDd>)OEd{DAsYQki-wk;Up6QxOCe!x{l3;k=BMXS zP3b5J`uU7=grvI zSctK;L!)(Y4wsTk`g0(YhDZp?py!6+VCD74rL*g+zlyv@E#oA+-_gx{S9bOBzbL>C zQ7y1;SQ?TQ=~(o5Tp7|oH*K?;Az@KrF(Ls8L0+4#+bD>v9F#EtrXBWTEUT>=5kb&d zk_~MJ*UK&AdJ&1<&vo*MpxXO%fx9^W1_eg>pP|5P^z_L4#4K%XafZ#@eIgNvO-5sL zo_c&bvf1*6hEy};TsA*mvgrL5TDjsNhQ(h;9K(26)qIeb7BQ@-daBJ@1=s5G0Vr|GuU|cepiS z)^6O4?Z64NzzMADtfBv;2BXCMr3N$or3RzLJ*bdPc?a~tG>!b&)7{}YV)k}7LWT@6 z#J2B^NZOwJgAZU^)Zdqv@C5r#t(bpt1h$~_%ZRgW>%ZDn{7Y{h!kYJ~)YlPEGVScx zk1;Irf+1aANrFzhkkqT)X&<$o0-ey_m~HJ&)EgA+=H}+>*e*V@d zm&xPJ6%>%a;g;Yke(sZC;{f%<-(^p4dnxb{OPg|D{(pt9K>oJGoMNAA2kh^EmsSgh zP%k_^9_i~Ra#pSpIEt6TbLYReT826E|MY5^e=ea>xjT0G@?WVo!sPt?`=>|S0kH6> zrK<~lZx3y})Pa$R{ps84YFFc;>6+YCNJ!wp-P)44vr1}gs~>>Aj94%Y;DI*S8a2H_ z%4hna@oK4@{ota=W7%%4%M7@vhFwG#kW`xa1kq+d5tW%VTnAj$$YC{CPf2g-&L-!4~(+4vK9 zMjWgo10c-vKa5AvZ&zInXW|h-(!}`oauLUVyd*?;O#42*vs+AQo&votT_z&Ua#d0A zVZ1DVj%p?%C5(%QcN<~3cx;M;*r9;Pzav@ewsv+7#NPk_`%RPX5A7-Wn%s~qRB3$t zBnK6BS4Q>~N2S%oTO<@LS_Xz&-5TP50DiIftHz;SzbXRc?7e%o0UtZ2TUuHI^k(y&Uj)1o@$v{e0+kylw~lfz!SB=`wpe z30DXFaQ@V5|1;1z*cKU;V0wGz4xkoM8+z}5cx){N{t=v%$k#I-M)zEJ_)x)23v-RuyFrnb7c-}t8w0%^jdajU}Ix*wg0uIznZI-U1?Lx zq?dL3R*utF>S(#e2s6LqA5^?y4}9c8Elo%sKY~~WLhDXc+hiCV98@W?(g6hfv&~U! z_XfdA+u|F6NJNmw2e-^Mu*g`=G%EC2(rUF}V4+L&+!;ZD!@fWzU!DK#0SvhX+hEc- zQ1iZQXxhrU0EdFY8kqlW{%_kdq%=r0rGSLouN{CffrJekzB8%gEPEr!MF|wc75~_5 z3qMVw!o%~+Qn+JWg-cr&_|{b6Q>tEEF9IFhm?R*-Gch-oM_5o$X!mQ}?m>I=*+FR)PckH6;w(91H#ZRPPE4$92}Ac05&7F;O^Vj)-;C zV*!#FV-zHoV|lqZ-a;2)34j>XnGe+6Q6`rq^D+P%?$quI6Y^RCJoeQagKc=qJMJsI zZi2B4y-^x9UXh^FuN+HS*tdaLzC2u@A{ju0a;U3!D2$hvK@~8ZymYsRy6$*;C~iDv zY%ZS?Fh7LSA%Yp|;k%J}e^wbAL(REOH!hL>%b`QaKZXu9wbg5GnQlLQs2Y7tE@N2} zHYFxHreu`Fd_xK`rrxwOU!d#KLt z?{`^)t6KKSllrWyM~Z`?G|<-+>^MYE37>>bl{#oOZUU-OKyE*1+S~98$-6j~kseXl z?a~T5)PW-!7NVg`lAfCON!gwoP@Ku|2~H|3#4;V+k&=D2K2^sEyZvCEI&`7DF;bXs zX?6D*;)XEV4bm2E#R-+1DMX z>!NqKo}SwLIsKFBQB@@PL$>@ii+lou|ATkmRVcaip@Y@`go@6sq={TOb{MrPuMpsP zE2fcDN3si4D?qwj_rDpmmJt4XW3=8Jzk#*NW-2R%J#BHvXKbzY$&}ciR7yrhD#U7= z28CrrR;Sk-ij-p}&K{~k?1y=qWPBFEmDpbfyqK0k6C8a0h-=NsS4WzLb7jh>!^ymX zFV?vTE|zs}smZ)mQ|m#P0obFOr3aSPkDlJcC5>%F<%%GvFAHL4^odeTv#;!wlnGxm z#c(Tx?HMy}s-u#E!P|2_^9gs}Pp`a}eEi5jffn9>#TtqWqrnUnx*1=mk-VmZvrZ-v z5y@huT6ZzbJHtVlF)^a)S?m3pG=0(dt{VBe8UfMV?w=kr_BWgz(@9!Wt!pzUiD#}hu&_q1jf1H zs_9G0rF}%ulWOf0*6k4}Mfl)ddV9CW*A3Bmc`WAI85^0Hn1s^Q+WiL#N`E0P0fB#< zfv3R*POUL4S}w6Vp=$r|1Yx5D+7zOAa9v;SM8(GD%ZikPgem9C`9%bMPddVDkmS=0 zf(RDf9<~b+zUPxwLt&)>bOGb8hi^8Q)?S8QhsUek!MF;``8tgLD`!YBE{qR=&+ByE z<@Cut1Gi-jge%A#56duqmmHKFg!WT^*{)|HVD6rf{|>{zef<$=O1BVRM#dDcaM)QV z62>KW&vWl&^*XX?ZOT@hj^lR>@HGMRF(lgvR^JeWzTPzVx7cq zMJOfJE{Vlh5iHfw5@CZW_Ek^M$9)k5-M(Y_?$pAC%bYZfe)rbR4Cg^kuF1>|)XV{!Gyr6B|6{53y6y zYjt!@y%m;qQm2}BmG>LYqZO~yrU(wp z_6JYp<3CMb7TMdsdiA=zoO|+14HcaA-0h}OP2wjrjwLP}?Jq{FJw8_H@^MzIDZ%?vyKRo&nH>uSlEM=Sd1DMxXGpaB|YFFt|iX zjU9+Hj{Hp`jy3C^7OPFS<=-$oN`@_~?;0aS1L53io}HxxU8^fza?5L^DANbEFA*&O z5NaDa4VeWnTa(x^(#xkB?qv@Bj$Py*0zjGpF^i^Lkza`XzQdfrl9w(4&Rduc8r>IS z2Q8>k)xo(uW8V4x3vTt%ru#$IOF5jVn3y~(ud#@rpp=5@FN|}ns~~Ny_)}8`@AF^P zd#=@HR9<9UjyE;04*4_-v_l|ua;L9)G<-`47g&$QKHkNT)JaC$y-I0)cK>0%6g^+_ z0p!boh6iYyiz-yoU*kvS&Z((Tp)qOFo7yJd8WN0Lu5m<07%FC?bq`J|cg)Cs_mq z#?d@0-~UH(=M~jN)c$Lcj#TL_K}3)uMMOFwRF%-X^e&*ZP!o!Z^d_JJ3Phy$-h1z& z^d_Nq2)&mx`TpOzIqO`UyOWz)D;LSkBr~)3et+-tphh`>n1P|Qm$S2g$s5kxQ@%?2 zGP{b!AqW;pw|T8I0#P#zY}*!xYXUg8vs`d1K>ci;>1Iw%1z}}c{+Ep|Vy;gVpz}$kM zfQIHdwKBrVGOLoX83Tnd=sS1jxXSzmdPzpP2Z4HNvt}|dSY~h{*)e1eRH;?4z#B+$D9VhoV|RS z53auq$$jr%#_R&C40{@A{Bux>G?LCt^_t)m9Z)<+ptsrRj4qXrx_m7y`pOw~b+fwc zoEvcK|2Utd=YNvHimE?Q@75H)J!s|z7^^^?^E1fq^(i2L4D_U^8rxz5NHxM^o-<8; zA*o!=r|1fM>g((H9aNs_Y0|#>gR3@tnC{H3(huhqFIlv z?tg&tyIxT1Ba^7Px??{?vBAxs!#t&Du`4|X*0pk}+W$i&1_5(5pe4Efe-f}@V8Ktq z#a$-|+N^xJdM+V9YTEG`ii?H)2_CmU(2Z$C9_#dP0PF(B<4-p@F%2wXtPT`r{y7}U z&+KOz9<;VH+>VMp)5CA5i0O;@@$3Q6$pCGJ_+0g6gkcNq0LID#${jQc^yz;s>{6IB zG7-78(S7mC$N@FicqFmL@nLaHpL63lEG9zUz(9;AeM;0Z$ znJTL#v6n&kXua@hv*`Eb6)xzH=>0 za;h4asokf{_`_NtV`On5a{`o*_@$QNC#R=Fhlj3uLAB|DxCLWgqS?T5>INnXU^DYq z0E?+Bjl{$>2aV;`^e3Exw+x!z+Kd>c>M69Eka;p0=ReN-dGjtmfNNlKTLtG z__wsQGKe6O=VrdFjpiHaV>-9MyP-r4r>!C0OM}&=z$$Sg{o&;=MXV-+gbs?;{HFfc zV>eBG#t3kGN07B6cE5*382_73#zOo$@I+!p-Fsr{k4{8mr6akNqFVpAyk?@#_*WvB4re(hV9-H8mxN~ItUwQ_DqI@8c<^|u77$6xVQ60xTAu$k1pw7Wi+&mCJAkop%3x~YlUu}tUNkD=wL|u_ST9`o<)tB*{swMxm^dKbr)*koA?rE1R zMZLvQ>*`H43sE!KZ&sHXJUop3sO`|g@wVrA-0SRYRLakL>{I$ zd&3KAS^e;))aR-JN|=DN*wj>TQUK67av{lves~U2`IB!{Tyy~{{EHUP#sA!<*(7-& zicO?Dj$31rC)Mz&A-Xx(5@;X+#Yg~Q3U~1{>L7X0T<#X-Y?m5G}FHei5`Kj?@^l#qx+ZgT#f)K z7tqwdVFL_d?XH=0dGyB*S5HOhbL8qnLL`5uK(Kb897w{hG{WlduSXZPoc9rrzPwuR znVx0a;z{XRk$k%GRo`Ph4hD6FmnVc>F8A4Jn+6A>&14T9HovHvIV7WrxBk+W5HJTC(Q7R%u zG~O{sJAqo6uQqIYK#AL>{c{aK2L#jeH$MaZ*kHs&M973TyXE}PGE4RVPY+~LYiAwn z={d%YOZ@yZK>!Ilc*uE8|6h8637d+=o4DjJzL~Fe5X*fMDp88#U>{Ax>&N}@l@QmP z261eaX?kljBS4{{;i;pG+e2jCeU3W!-JE+IQ`DwSzWA#HDcK=5K0*?(w_}ISQ)Q|X zM?I#=?!oo z&t^Qg0kXr#UYb=Q+m$LfkBc8X5VQjbrhwxJNnBl~+VSZ4l&fFZFCfFS^T;pW5m+o;6C7oj2`&f=o8EfaO zq#W_R9Mt4^ZHK3#PvN$baNFPKbC~Nn3`hSoDl|#t#`Mg9?(5~@xyr}p0CGvdx!G-e zvOVRv$s{GjW~q8V06oy-lE@43RO=CDSpU=1IA!Ovak3>zWqZBq%c?A0UH-z}cf*bjq$0jNlga7gn7KJ~u16!y=u1U%ApSNpiI||On`rED&F_$a@ zY83i>i$&Dk?I4`>_1_pZ%@oV1Q>3$(zyHj#@wfEajWCF=Pll#nGQUkN)VRXoXz13`3u&# ztrs)|1iM4u8cx*D%*PzIXR3$%gMJD95Q29xCrZO50I5&$OR7WUe&#bYg`KpkiQ&)! zkOJOw8(H(^DSUV{2)TI3 z&|*T`bRxd19iQD0ykmwDO!-iy*CMgsNsMpHLnTa8&}l9mcUjx%dU058YB?wQlGCX3 zp0MFu6SbuYeR=W#&)i&EW8-L0JBT#TvQSOoCK~Tl*S2Uj>HcnaQ*^q%iS8htD*M`2 zS&s*+m`h8>edNg|vMMJg_gw%!p4h4D6lK|)dm}u@>^br~LD5{c<7Jp~cEc0y5a<58cCpzBTA%!muv{d0H1- zEmuh3yhLwCW1vs)8rBJ~FNH=H=+q@CU73nehjg~gDs@YW>1R(ec(ewG0u>=LT?I zD}~*Zx96@W7$la3NY#C3w?EzN`GSkQ6faqr-{KkEnQAnuzAGsO?!P1;Uw7_O7vC0i z{%z#_)2^WwCGG3q#C1PpVJyMy>4wY6H^cUp;14Kjz3#7EqKk}}vtq93g>tr7RD|MB zQkI6ndOKZ7-_lQC z^xx>P&ekD^8Yw#iY;V3Zm3|@~6|1e1)ceBs>ePEDfXixNM12b5Ms5ci@NukS^s+4^ zsTMAmov99pQq|LFN8i84Z5Y|rzZmp#^|WJW>m=XoUt}wBvxvQ`6Jgd#a|x4|lcS}2 z6!${N#feZ7omu#$zu0n%oNST))_9@n^FaJspw|A347XXpR1Ch>2;moM#{AQ>8hSu? z!%slQ^k(<=pH<-CUZ{YvGo7UydSR>fo}PGMkQhhX0(a3p=K1D#dvgz0BN?5v$&I8% z5EllUzs(S7=$|x z{S;)!;@H@_ndu^dst2`!Z~s*liYGdkGkFYIszi@rW-I&O2A)*Uh1c(d`{*B zjI0jgKcyX@6l_^&P3T|adLk&Vx-}kPStZTP{rh{5c)ZnkKRtncQxn)fTA&8fq`55H zB62o@Q_rRAqej-iVZaH{#Qx(8WWuL?ZBAJG%Ad}Vj2<^)rT{G#sOsNMIxDt2B+P$s z+D^(ck%Ibw3PNpInhUnr&nfP}W5+Rp@=UW%QhF|Qk-QOA)3Ydak#m#lU}Wh@P~5z8 zL(R~TRbOi3t&HT+MC*lh2sOE(??&DE3r z$U3Pe8V{D0C2`>>Rvc$WKB|B(z4mn~f%c8TWX#`%+??%9tp!#l)w@YpUPza=uEHbo zdiaTlP8Se>NE6NCKKZ_;^n;~9yyQjAKJ~!UEN3otL?_Kbsq2o55z@B?K_(`EO1uDH zZVQX0z^9W_lQ;I_iiER42X}r1u!gsJte&$oyfJx|sGGQVB{uSYFj0KQ^z~=tY+_Wp z=qx$k>w#Ozo`AZ86<$39C|5Q-HUEN(K=#V18M4sDyP~}}g zXH2)8KDpspIlr#}f%8C%_oDr%AyMBHb^o{GiUhjB8BcfKEwsAkWioR$ZiJ5bfcEH3 z3kGpjW2b7tX}9N1S9qZ{yXLhh2A2`zYhSb>AWKD|wsw73{M=nW=1N*X-)LT@gc`YK zZ#2FA7~W4t!M9Y{71@ZdCbDcG;pI+F{I&$a!WXw(fD2^JGX2=4NzxlGPPTw)wS3gzw-!kKwJ1 zorixo$++U|D_R`|U5`OOI-rK(s-hyD1;f(4lbclziob*d&i3>4pFp5RfhFz#&xVSU z4isx=CJM;1A|kb?PM%Ov0T5(atj9UijTVm}PheHW$%%hhJ>%h{6-pp?v7LD%?x%~V zHfKO@SegD=A38IW@LsrB1&pSdY-v;-Y88DRvt?S-A^it1)1kfP*n#;QS#d88R z{E{(^tVxy3$3SmVjw!(U?Ct=j`6m8^bfk}?J>F%Rx2coz=LH-c<5Td3-{Uzd3=2JL zAq~149@pR3_jG0x$XSTY{5BiVc>Hq@T+LW*VmKno#lDCeA_V}hY|(LX60VCGQ=T`A zpF_U<&MeuX(Fp}UfQjU!sS`45o0$vI{PydJKj>YyGuD{?|8mMTALkI2+)z`+s>lPB zFzkw>g0k|A%5qzTV)g2~zuAV`9aAbSn%UeEdgaAtS-#ywIDNpEe#r#VkCqqA#S4L_ z306Dbbq#8-q+y6-8|oJtxBe7Xi}!|>cG0soPAa|bTN3vryEz^>FWz5n{QPNDle#oR zwZPBWRl=(8TE1j??zYwMB<^tBfrI>Zm$3l+oJxCJaV7R6303UVgY|hl4szyjrKln$ zQ`6LQNk&>8yhVci5UGRS1v}h(`|7iWL*~7OgMT@s80d`9kVy4Z?vr(0qLrELy}s8=#&x3HKL)nitP@Y%K=gE;o~x|ayy2{C zmL_=U@Af1-pZeW+xs;~H19mL}3G`oL0JV9xR<)COJwAi=?iu=T>TudNpFal%dX`Se zk<5Np>Q$nZ*z|<(!~U@oFsmt`r-d1keL z6*V#KOjkiYJ)Z-h#}8?G>gQn#53z<0<9~wWtdgFxZ{HU9{2OzO6WZQiSf9!-_96Xe zVaK=HB#fRFMH~r>`JNFuHKZl^>>OCm{Sr@1I*6zR=~fEu)<417qPDY?pD51_i|f6i z)6OPqJs!EQc^of2FRGjGKXccE@v=HnVPGqrvfPPqm@@h`6e+kLq+kqL@hE^*f;39W zC92@kC&}Rb?O&P(@w;`E0hq^I(n@LF*+nx6!q|U0y&>T2p@k(sJ*Hu~%bmMbR8JQh z8~Xz2JVl<|%xZD8}f>^zJ^y}*L2`J#r3!5s>N?*I)w|$ebFh&MUDLsTo zV+CE_Gu|W$U3(}fPY7;l)V{JgM69&yk*0io|K2cuf4i%2((RyPY+5l*wf<759mSne z%P97C0x~~Bv%S{5plKH(`&f8FB;%v;1f*w>z>3(MPzyfrl{XxFtw(Inx6)uiAOH*e z&3HvFaO$B@S{Peg%GaoPJ@W-_4h}r{YEvLBFIw5bA&>a!#VN99Q(Rp(GnD9XHZsy| zZ_4a8_!xE7X&g6kLkuo>9R-M7r;4?rGv0P~jK%*w_{zb7s&FDyO7Hb3s$Xu?04`Fu z*wWxd%TqEeQj_mo47y2PD?>$XTUD)b}v&7A@ya%G9 z6V^}8zGDFDPsjyXOMfv;4sk&|ny*g}ZA~^!ivIJ5@(M_(4fr1xmebP(Wo1s)p1J)r zns|_CG@nefko?!aSIIWmaDGw!-Xsr+?m0%l{!eo9FYX*lDsEgL*wVQwaO)KO1hjE0 zNGAYmV=h+hyz*L|59_c1<~mXmUc1(iX`9QF;?nCaR$HRw($>Je$@IX%u+qgv%l&o= z$owtjl9zz%icZ+B;RAmPMzAv(h`ia>Sg-vynXIrcCzb>Gmk5AvuPNcK%>uYz-O^0& zW>q*weDG53$ki+|q?JT%hDE0Ve&52xI@+@i8n@u|cR%(Gs33Tb!T%h01vs9O<6b^8 z-<1wK%X5T}meK8?6SgNSMoqX(JitPg)TOrO?5Pa{Lsc)akH)?`!X)w8u+Hzxm1pN` zD9yrY-I~?!T{(=bQK)Ee_Yq_H-e2W_D)3I*T8S)tsY_shG`c=ugjICT8y8SrhT4xW zi5Il7J<8IHB!&SqznFmmK0cF+B4UZe)_KRW7l7bOaxe(moi+HQUp;HFh2=Sd@K4S&V*^!Nv>SRAfGXN6MrUO-)G=y)FsY)rnKp-~RQsA+iaeG$iboJ6*GGxau zqjI9{3q(jh<&o5wX~o|rPYWHAd!-*IaZjG8$gsE>jN2`|PF76opVURp%Ktu3nPfyRSSC`7}kuH^(kCln_ zShSoe{yS$g2AH6H>wESy?~STz2v1N5V3Kjp-te^H`Oi_l2&19bd5lK`j6#)xg~R`I z6MHzHm(7OVPxeKk|LcY~M@-)wYaiBf!?z-`0sgxR9aPv;3v4_!T>1TC6ZGfRfrLV&QIXZrhG?v?M%bWo3Y$SQB`YN<5?##|qEvK#`*8Sr?G` zq@*!!H3nM3sUCC@L{b?y46!`<5;sqM@H<(_{AU?<&n)rp>A_ki=Z!TMQYLz^{k>_l zyliYONUYt6U7w)qZX_`SGfnry92b15si`S3|0}UJxsN(#z;3$*S(3IGycfc;!cF)^ zO^#!+@KtK{b85X|sih*oihJ-=j?ztSja^h=qDdJzraoQ*YG^CVL5~n)I2;H6oK{Dp zRWU8i0tnH_zj#6PvEWYNffPu4)!m%R-}dE$ABE z{EI8G@3kSBebbaoeeq~-n$zzBeWbA}LGd_>FH`ZZ2rWmq9TJ>iK^xaV;~unupp7@~bZ_UM znVqv|cDK)IpdR&J)w@-7<-6aFR#TD1ME`*P;>8P0c{wSK7cX8)Bfc_kUL&q(AD+7) zej#~i$V$AZog_U#G+xVr)6(kmtIiu%FMV<%x@ABq!ANgHB8 z$&%)Pxn!gx!MqA~I{kk8G?llUZ#=PHlHQaOYzi34;pg_)J3elJ^g!~m<%vau1M#8| zpY}HLJ$4)%9ChH(_kV3Ub2TZ%r{wpivM*;l9h@uj z&4=t;&kz4@vnAkUI9vw2n5D=`oae{R!uN^q@c98tcWU+8ZIGv=qM|x9>VGYb`z~m| z3J`{rZOQpdmmNQDtT$5j?SSk&jf#;!g`mp(rfzwVzrH~N?6I(Cu=n3Y1WJF2&n&Tv z{4|U@-QU1gRV@siIds`&UY#zM#RhkKk=#x^&_rO8MEKMh4RWJmZ z!kw=P`DCR4OOr^(SGk3*;J;#3NA`Dc5Vq5wUf}%KbGtv)hi1=SeuZY8FQyz+^R-)Nu5y%Jf+QKs>y0^Ugo`cpDGL z3*U~i-F+9m*0kqTU?lOzwMLFi-XIF*01qa6l6z;>h)kvbzLtQIyjENt9#{VWonP;T zn_PRn{UG$p?tu9281jCLJ(J)7ujL95j56mA9e_7Jtoj=gyU^^QH_M4UZDJpxk5+au zG1`mbstFiMO;#}g&i?kHPfmqA=j?Ku6`Ixu+&bqM26gW&CK`L|)8|2SE|RATpAGUMCXYvDiK*;WTj_S_?+|3vs}0veug7QCfV!wCgY6kviot~%L$4||z6Rwl+tOV1 zVU)?k|A|wf5GV$J?*D!MMFYMTG$niGE@@1*-s6cD7WsB&?AnCA;CWrtd{+3@Y^P%8 z?%Z&JFMe(nc+m4hh&<8Rx&f5&EhR>F2zm@|)4a7+ur3b$EDV#}E5%*u@TsfSvI_p& z8`7?khVFLt#p{Uzt_QtE2*(IQy{+f&?*4S3aUOJRNilanQeU!FpGa$+a?YBPNxF^L zvi|V#aix4V0%#he<`t@ROXd@_2Oj*aI~=UxCm+luqcBRQs77NMTaqiEz<3{nLoH*r zGo0)XAV#J+xZx~)(jc$&gmzx4z#SSvdvg3%6w9Z36S?g|-tCv9BMn-!7wx6h?#??# zqiywx^mV3_9gfs}Vb3e2Mh!KcD*Qt?C#%LFrsa~9OpnXGT$h}*VH&-p;1g5WMdpLA zJBweJB`5*YpMCdftTV5!dSlPUTe}H0SUcTY)Rvx9SL2-Xe zNl=qtI0cEl6Ug83l53-i2PUIyjY1u-lNKpM!bmE6rN6}HU6RUIMP7neN=R?+-CIV7 z`P0p4O&StAhxDeJgUF8;I1KlX(tDLgY5IO9`2`f-t_QI1H*V5h@!dQUGv7O|^yfFq z6$LGnYLC%DRD)lvl5wngxnBQ*}OE7QPN*3(7tj2RUi_p4O9*Q*_0&1(^yB zPKCCTM`flzYPj#@b<-U!ebA(-k+KbY@tCd_W3^7bW0RC7KdQC6lsqbqGFZoiJ2F3h zd(9V36ZX>ZXLBJd&OzQFGtMq0)!|PG(7W&iPK8&C-}j$3KRn-tMc?i}zFQ7%xHr{S zvzmZE*^2fx$db$4Tp4x68>O)C)KhFO^$bZGk{&aA?ksi-d&|l3hCeAdGYY10ht|gzS}AHK9OuSAaQ z+GI_m1s0~+?sIC)26-gyF8U%BHAKfi@HnGOZ+TVGCtDLC`b(NV)6V-riX1aQ<@b`HI|*Ipl!Tg~QHhIOCWnZ&H>sB9as4ymjEe+c=5Fji=WTO-^WX>HVG~d1~1(*%|qq<>sljh^0>qj61k zmn+a<9R2GI$)Y;=oLsNeV8Ud2->cB=UFuvY>o<|&+~eJb8lFcT5SXsDz{Mt=VI3eJ z@a%Qe)c{)Vyk0PT@LJwYcCw=2XP-T!w&PmvOCSZU>)ZgRdXmf}Lx})w}6nVafar5ThBX}X-+S8DZ%AOG|Y6mqt5ZbEjvk?R&H^G^54ZY#8 zy>SeppaP2jxe#W#Wlw208H#R^QA)gjcYEd7fBI+YuS}ON`)&Rm42 z7${K9mLBQnWehjMCp0M%dFMSYdAS*VA*N?XDbV$7{aR|?ZaH&W@%AGQj+ZJ<7*(#& zjpV62E3X&n7lQt1t@b(HnmJ_aAj2DC1v0s__4w zmfZh;KBMScM|w)=M(KLUXtd8}4;Qzq|CyQ5(ehlivdVvtW!O}SCk^3#exwH0Vj;V% z^&eRZ-R<7h-_4N~PY{ljLxd^Xfb-*ch~a2|cSuHj+}%?K`@vpnuy-X2vK#l81_!wX zA|81Dw(3o=%%Z7S7F?mQ*H-|8HKj;NBx%wG;Wlr7_)USrpkW=QWvj^aPm- zPw4cI3KF3=>y%;VH`A1Sm1V14{^s+$V>Zx)zFb~h#2e=FaoN+XlYYkcP|cHq<^q*| zN-CgFVtt584ST&=f}RusZVgH7ykyhZfcH91ck`9~lhn%Zo!iNeCl>@y&YoFrQp(Ci zZU;eDymuVXrPY1F5#dduyrlL6(WlCIie}3TZ+^hz zNoL?l)9aMUy{uP{besw?ox< z?YZTj7RI~A)bi=_{FlWxYqpzA8`GoNh!GTOD&i*XB)U3;8 z{dZow|CO_Szr!|{TeB)fW0@0=17l3NbU&+@`|0A!brH|%w=%)GEk;=u>o>dJj^%QG z?KK`LC&x}yCwOC=;T|2+P7;?Nsi^Fujq#vTlLxQkS_215q!=j=pO6dxPBSjO)turjzT)IDAT|zhwPD};uX>$CV zjQ4SMC&{$cS6B%r8JEK|{+x931KpfglO@Lb{{?cmyVUW}?=E<*o;z<+aXXIZM2vKi z)SV8==xEP#P^34RBm>%tnVlJBn_>hqoK`dx6E@bZy#;jwGD8s2X(5hoU6Cz@^Eltx z3Xhdh_e<#akk<98OnK#MowZKRmCrX1(_+jE);K3bR4bn+0}b8drh~v2ZwPX1_giKX z7^%p3X9aNDS&p03l3UzrsS)c_W4g|R#NBnhPvJf{q<0tbb_n37!TFr;lk5fyl~*Pe zd8{V!z{vZz?MKr^y3Px;iSNc&>911H4J~ee3%WhtI6Y;LQqKqsia8)u#o^OB8Q;5S z=XFQzv-A208Kc8L7|^#dP^K2WU!@f!Zbu@P%!sqzoGBXjA^P(9k#foq3INM??u<4I z;|=||Jz2f`wp~vd-TR-eSsA2&62*HwKTtun^091k{BNXc6moI*+$c8lh&V}ksAbJm z*6iMP;XaZz6r11vQ+3U;!9bVo2qQn6T9HRDo7V~0_P5OR?>_a>PC?)BT>A)l(o9DCzQS{E0iFrFW` zpS~gqu1E`?u2r_GR~q6PELKm`?Ajl%#Oq9md@t!DOnO4h z7i-PGuK@1;R1x{6v;H$u^Jt~uZZZ!s8vlxu$cmHQj3f%tlYt(T`Z;Q zs))cx?cJiu;Ng{f_kfDsTbHuGUAbYdf;5lGUA1PO(6QE|dKD8A`-Rrj0E%ZD zM>@8v({SuU{#beYLmW^W0qdKXGP5PWzbA_l%!+s%n}Ivu<#byH)SOTy?C-mA{`fv; zk-am1gPuYcWWy+eEXZ+qEUwO#F51MB{|rVTAiNt0o0#MuiH`pAk25?Mc$SSY5tl9C zZGNsck4A9CG_iJ^*5gDe-w&w19DGJ@E;uS>9>Av&6>#~uFfTa2HG*ftsv=MnsUW2>(ZFn`^D=|4SngrOEE#D`{81y`)mk47{f3=X*rz2!m@SI*$#@rRKIiXH{{UBFj zU8(buit=`Y(=YE}8P9gR6R7&>S=0G1NpWyAxZ8!YR31g@&=siRzm<{GmcAvjrjy!) zzf4YXE#F&T-s|quATk}D=J_Fa#X=_o}Tx0Ijb|hqPuq( zK^daJ9HmzTVU-Aj4k>w$?80jf`R3M&56W*aoY(O5J~EHf<3j>HA&tKQ--a*uI;5~2 z-M%P<3CYY?gQ{@P-W+g^i6l}y-;uf(-r+}(!H7)+_P?YgUE?Kmc#p|Ds89MW2m8j= z5Ni}nS?1Uc3U-6iB!L>%=B??ZazN9?O^mG1gNF-(5O&iNF9o%N$=cp&t|#;?R#v-+ zQ$ne;BRlo`JPW@yjiK$V3d8HSXf~k4Shbd=slK*>X1= zj2a)G9-s4coVtU1hy8GXFYpBv93_1AVQKqy_wGHhe`&>6g|3oqUYX7N%SP85845B| ze#6nhWjic`eOB0_0f#<0USZ;j0t{<_= z6NcWp2tR^?^ch>?si5J_%}4Ypg*mP9>X^GpJkfy_$l}omgW9QE-uZIPL zx4B)@GcS3RwAYy!QoRCHEbh`Ov#b`rnbP}>wvwcl$DnwWDMmP3CoMiRlP<2sGS%px zE#_Ph*%#HHIdbzufBI~+q@K4T4C@2zaf)wt?+Otez zt+S2vhY`}`?9_?Be%*FK5OyK0w;zkGAEd#`^>OznmXy@M^$#}`DGM{G;lAAv#R#0Y zpzsGWRTnAlyXaJ1)a~@x+XvI3U-(rWta2Ep+h^D!z4R*n=LrIU#qj}KYCChaSERxv zOee>ra>W}Zc0V=dkrD^$xA{bZLwmOJ_^JuDVG8Of%Az_1rL}i8z5C;B7&gYt@?Sq6 z)BKb)RF`?H`==cpe9VXPPB4fZmD*UvMwlF75DIZq<;@{y}f6lF@-B3RUv)loH5=YxdlMXU$WH zP|`hfR_cQhS;E_j8_h;t7u5DY{cY=32~HGzA^vOa;ciTCPCS?&mMREQrTHE|uSYZy z?tY|a_*>r<{&PUmYiwlrK$GM6_iCSbsu}^jb^uaCNNXIjBAzz)Mu`-=^I3-!Nv0#^ zl%a}NO0~9z&rm!*2IRg4$&zjiku4Uvkz*>;$(zigMy{gUO7NO+0HDHjSyE8R5u&HZ|HkNf(AAQ z{X5G~dgi0=VU1_RhU-9PTv_PysrC!-<+w? zJhfao`iSRA1tW31btmy~&z?p_&2IPE7q(@YIQ*sRxQ`L%sn_)N$xg6mv^ zV|Z-j>8irFSUc_8k38GSZSew24WFuoHu)A}riS@`o>}vG_otbkb zTz7wCw)~<`mClNr4lhW}G8=t<8(%^+P`$7s&BI*F6dB&dhC(5V&C;XC|u83K6_A0%f@~HNJquj(xAeAX0 zz!Z+>8+eB#3}1Ql*`|NrSW`7MPlGgr050!N1mF+ z2KHV^U4-u1AG|pCx<0R26%qq*d+s^wfa?DY>MIvB2zmsq^wo%oAEyUs2cS)5%;hnkngdz?0?!^Bss& z6tpM;i zvC$fx>^#NSX!tD;8#6UX$-hTy1G%^ZXjKdqXL5Yj4OpvByCfyQ^`sAAJldN_ z=~4_^_!8)UmRsQm#|muS=+J|&e`-t@{153mF>|pIn9PE>@}nB8s$fw>@X934BE_s7 za!H_kZdCCPW6Q@T`yyP`7nyv~6jc0K7?U0qYaY2a*XnQ62s2A#@cInUQ|&7HNBQ}F zZ=Ei2jd{UsoAv;1{UNBw5LW1#mL#dMZCs)`X= z9ExOcEN@-HP#u}I=YNo11=(=1kX3b(r$eb}>sQ!CR^q$(Fw{+90zUaQ(Z36wByY$&_B*1{J#6_V8Z{+0vz{$f1=c=yUBR{=9J0)JUz{JE!CDKOkWlxf(`BW5Fu(sYDz}FV$kDX|2mW8@&aL3yIwI;a$$6e z3Tw{eAxyB>=Yq&O`ULPm#onmiTWA0obe%kArfb*kURWIv6t!IR{Hyd|oQ}~t17vSW z#E1h$50e*{ysTWe7XjU33EBMe_F0h0+(96g>H#TizmSCe&Ay_0zRu}!o)fzdc>FA+ zTW4|D0X3hDPJJ8ow&j07tpA1W|L=e+df30y_rEaKl#8uc_KTE2#FfJvjhvky^#4HF zH6sNb9i7Cv~o~<<|`7i z>-G@lv#0T)(a<{2$oPMOe?xO?wyZ%E(K|%ct*+bG&HiuTQq0hzj`Q6@o#OTtCDG>w z1$hl00I_HdjIlc}4n=9N0nyn@oRwVj)0gRwMqU1YSco-}T}7QSS z{>l`b7kcuz#w9hq;s`z~lRxD6VQtsbFep&A|FFw!lYo$@!y)(go@@`a7!3^#BM^Fc z883EBN%$9TI!D)6Qu2}F$x-wS{*N`(HjMmWzeOuBBCE!N7`r*oNFIS8g|<(L+_p_^ z*1-GE9Q(ha-R;YY#-uarJ|I)yGmoVNL-KuOy4hzcxB2XCay1D$ z4h+Ud)aQ!giA)dQ$kBYv`n77H+y372{rgH`$IS)#g2sB=;=*>ER-aNC)yTD$M z+$0RXf@3j#tl(es(1!cvk0)Ym8DHILtDy}D17-vbx&#+OV79oU)@qg-@ zX1x;eHly*WNnrdqyZ#&d^YLKyY|`lMDf{+V!_%Hy-dE}`8V8Ct(!^o-@gsAGTpk=q z28~y8xO&~ZfHMw4C!z53*)+V`xM-^TMB_Lr&C`yaPMYZ;n^)ri?F&y#c`V(Obd#0fZ;od}2E7CSw5cRE-v3l^!emxu zStsQyNEp`odgIEd!Z%wZ<|OD1-Gx?na+J*$g$?LIZ9-ZA!i*cwQFjR zQk-%tYN7i5alBb5zt9kmfnFsq#X|!gE5NhX9xr0rT2@|`3p569{$^CyoQ7~qSwJH7X#f7E{--^N9@Akd55;@cdCy{IZ0P2 z6{gpiou0Ea%^g@w1J7^_Lm1@K_f3MQiQAC6>Pc~J*}O=%#J>S#UdMb_r)z>wwD?;cr$`ul4p7ud)YrO3VkRvIP}F90jTbH`b|BpF3k!1-W^@K zxu_X5G2(!B=l3*o9mFIbH18gsZ8&Xkn*vc26S!lIL;aPRKK_LDz>&jEpK(+Qo^W7` z#U})5-}0usg}w8r0!4`Nz5M%bshIMc-h#dWU(GO&yV2rp@S>vbWX8l&QF+}~ z@c8e=mTEDqSq``%)5#IH8+?&>+u?TgargmRk#8u!&jNSKf^e3CYx@NBfGtTd9S;{Vst78-aHN zHhxW~3io?lwwbe$e?Ett<_1vctd5D;&#qQAO?SQoH7*7b6&lz;Z)pRW{Ixl?8*VEBeAO~>@ zH*yjdd+y)+?%?p$*!A~Iuy;QW8<0vNn`B%;pI-NfsKp=LPBh)LiM}Vs>v3^{W%@{g6~c$> zUW6}afQOaxT(?TmKb*j z693Ecm#ciHm6%BAB6=*f!0WXtHd|nP`Xy8{=RAgYcNQt?s0@EhbI8mcHoBrUOLqjm z$*q55{`oRBvEBnWc{T!)(3`sfqYrdAqIh`}+(V(A6@~3L?tQD$Z|p)g2%UfG!sv+@ zart>-UFZU5PEg4yH_QiLxT_3!K#@)AZ&vk6nf4-I8tY9y)4*RIf4Z-7N>ur6i6M&1 zQ&^Iw@>S2j+Rdk>&~i^xj9l-NFUW$$rLd!c^six0ynlv#t^p$}VRZG0^(fO0M+r z1Tc^oTvC?KFQ+*5iJ43#Uq({q^-$%qDmXJ?5lEg^A;+31S@1xA{{Ty+tF!NfIXJw} z1Bce2&Ly3~IhV|wAg&wm2U)VKz{Pct9T?0hv_{P!}8$-!;}uFx=rsFnax>_`~tik#?@M6LZX1 z+s|i#CA1qXm?wVL)epg|Sy`x#cwe_@1^+=X5*=Y6CZL$tXg*m27({qC z_71qwQ;p1oc1Bnw_ItyFnC-bnOzn%Zpc?{MRqOZUfF{w@TPIjfE&?tEsOnfbZiauYN`;?SU_Jg4Q7_z~(3mA|aiHS#8$=%r3^Fq&^G{+bLDQ~RWA&%Z~|M8&4C1rZ-JNDBTsWIajcTMY?T6xmE+mYYPDLC z+MKE{5&}Rlb4u!(3^}w`(AW=?!@?5{jLnm8*5Cq3kLgp?&)3gu&d&G?JzjvhaRC3@{zU{#ZlC1`@5O&@ML~CA@Vb9h>xnH0)&G z@CWwo@WW=NP$$*r80QOBB>l^lAuuD501rQe8)P|jQ&_50G>A!atNnm^xMrGf|T4%m7-UHf5T#F@}V599I+2-!z zGxwiwz_95n?Q56bu{_TE23Wq77i#krm^{8C4tALR2Iw;3y6lGkhH>L@dhAK%-Pggj zOifePqkN{`%ASq~gx%EBZ)UK~oq>hG4ttum8j|ZxW!sXOs{`(8A~_hi3qRtI6~54)~QoK z5B2K_K&atV)?8grBf56m;T>Ef>YQ%E9T$F+4v+PhUGDy2*k<6JH>_14S~29A3elbK zxe~k0W&$nrwgU=7&Ws&;U}z>ofq{lCj|%PRWlHU1_iw*!9^L<5tUmd3b%Jue^x#6d z7RQQzaJjsd%S&gVpdd2pJ=MR?Pg~&`z3#}E8vCo>g6@WZRc0{x?+H~+qdrLgo2Gp( zQVN{l{Z!O==Zxk{v-h=-`FBka(PX<+y_}0DT0WqL>v-ja3E^eTNq1D?S(=f z8I^$URdsV*iOibkkit4xW%1)k7{ByvvlZcah6&txzZ*-Z8{|so(~i!V=7D-{F0n`9P8@I8dL)B^JNWxlj2Fy8guGcPPL~nez*`d@1P?cPU~jHJ55I!}=gw2v zTRtxxek0|S>480mIj=7rgi_Jle#LX(m`w?ipruMonE&~`B8&$kCPnJ9x7->x9b4U& zdmjK^0BpeFOCdAlBIgsVcU4zTcSEK<-|*;a1ko{L?hcAoWc#PB^1d1Rcc!{3LVj0r ztJJ5MMIosheu(V3gAqR6fflZG=c>*D?RH{_h09JW6tjzF+-O9Tmfl@zv`sAdOrVTq z!mDrKphJnB%}~7*FT8u7;HBf?6x`NaS1|veHhDt--U;3DXi4M~c2zpVgPn^jJAD35BQkAjv+9v|&U32cgyu`G4zX)*lnl6`5;=XzjZ4S2Gm zIc@t%3O90>X`iU$eWUHvP~M-%Wc^m6d-0&T4mB=Yo|}PKR%s88j@H={R4u}(mYd9z z3&Q&_LS9)Waty~Rn4xilVtM4=`A*3&=#RLjG-YSiAA#k%FBn|9PM)NKD@R3>!?!D! zdbb8g9i9JVIP0n-hM3U+zBn$bG}Hdl7ZkdsW?dA@Y$6KZ)&M1qiyFf0wRUUz&)8`- zp)pZU+c{g4mb1Rvv(HNoG3%ZsFLQ5gEskF~@Z;1~b)@=4;L)q(TT&@}P8KONZb8;H z#i2W&$m7rAE`MWa(JG(tO04SFhr#rPN_%r; zAH0PYlSe^_z;$YAnhK8E*KGgcL2hRMqb%kBhsXE7VWsALFjciFa6w5r%RB{vkfo?= zph({g=+;yki3*#XJ{Wb|YW}7NN4lTlIlsIIM4P*5Ayvv&7#3QY;%dgX6XOt9kSv(U11Wjvb@BNT9ZzfJO-VWz$2;M^-7-4E+8M{{(pD9fGj6 z>ZH{bk~1I3Vw&WPOIkuusSjN7tTwv+q$6_Oq!937cdB|&!FDXgb5NLj!@t02{&`s& zu{7t34XFLK5_=g`LOM9UZz|Kf-<0b>2gbD*Bz3wHoI)wO+bOCf$r(r(tx|aE!Bt&j8dEXm89ydatB(t*}PDuc|7JE_xgd&=8p<3T<2GFZ#Sfz10@xT8# zCU&gRjAdG4q-Z1?WKUE-FqK%DbmuX3K@p!@@13>99#zRYpEuYcO%bvHZ%}IZMMqTx zys0f@`EhNeA^c7UxSpjT#P>o!TNVaR2Ib-6;I{>}_9GzFl)z7NW}YiKw^`Z-C)nC&VSmkJQB^h7yEyH#)hv*}PzD>eo(1LbA`r*M*)MDc zxwiD%$6q3sN*YS{3$&v?cKSsfygQLXnuMuUo|zGq6v)&(r0F zP0&w72%yZ!^K)a=FQs|Rrp8JbWPPP3Nc;^N!HIt^H*tWq4-6$A6zP4Fs$Cb#>y&b{qOxHFPiXA1VZY)d6!Pn@%?olQE?;Z z=Cz_zideMvUsPZqYL`w=@f%9x}cCA?y( z%P`%NrIWJ>|G>SrB~*~n7VYM~W9qn;FPjOyYw0QPpuyoilP}uhpf(V5aAINP=Eru( zVg2<%QjqlcBSIYvvQ@q|(0u%I8?u1VY-AOhSWR^Q&~JwS2zURBmi)if83GdiKbgpf zVqILKaY)zzOi7r(a0F~Z@u7}I(cd;z!2gp-@~zv`{nOOuj3_`4o^CBqPj&bKLkJgZjU3VQG-8+)MLY`yapM_C8y?DGi8TeDB>-Es)0XZ8YaoNAZW;1?^N7R}vv(Mumf+VVHSa~MtVZ)cRB}~l=+>y%E-Qh5xMiEeCDjVIt6+TGJ%gcc zToYM>ZCcm&pPYd3g&BcbiS$}dTd{dv1PKO=K{QK3R06y4eyU3Atq5wLHSiWGaX3y; zr#`g*U&5DCLGF5K%Mh~E8y<7XIy&5w1?6QEA zl+#DQP^wMga92Vmj)I&RrihuNeifEA1^eGTFgx_JVIFe zQ&XvWL>PT?$A-mQ$_f7@zx=qCg8xI&N<0~0yc~K(w7@9tCt!#d+HtP4ry!C@O;X9g zpl+ucZrA+*Z+J=|Azk%*wL&dZtS80_K-ryUGNW*vNo5U|K2_ZPAbNe#;X*!C3x_D~ z??#y#f;I4ONP%a8tw|Y0h=Ryf7HRMO=^qQ&?UNHSEl`f1hvQs0>Mv<^)zbJ0@x~p; z>Ed+>lI;kV8&NxScPkx_jkL^u&3qC*S*dy%t`rB{&d`%vUuXT*$oBxYNspY#0AX4i z%zPI0nWuTR6xQf-wD5zd!X1cFi0~0w__fh8^;qTG6-%md3uq^6NwoRP|J-#b&hAZm zyyE+pK+#m&hT0(Q-6LmPSW&P6Xa6*n{X}=j=!yE!VYsIc+*c+me^HSXq;=2Xlg(r* z+;UnWebXd1&0%RkXz%T)3H3cb7XL#fjUo!I6dSJWe$aJoIp7Ckb0Ec0@9WnglNf&U z$P>gyiDw`369HeJu34TbiV{yasJK6wgKPG0NHJ|CV)reU zQ8~rwXN=wxG&bn_$Y*&8sLJlP(O}rw{d`sS$Nq6_Q}UC~As>6z=%T&|zl~0d=({ob zn%|!pRkWqY)4k^YjM=D6@K*j}g=pH0a2=j9ww@5lK)@osuQ=#_)zhIl#>qkMny@kT z6H$Q;&C~HDyfcVG-A4RnL=~Ug^8;ohKK7-7w%Oue(OW60=*&;z>Y8&nLO?&!3!Rgj z#7rEG^Y{E2x%~bT+ERWAwN8<)VrQsT({?%5(I!%vV(sox?|s&OQo7R&6^sw;ntf zXKRXwqX&yT!(nQu5)v;KlTO=@plnA=Uz4-yUMcX>NBwEe_`^Z5SKS z@aXnW!u}NZ+1fY>e82o5(iE$d-qD{IKvmR|q_t+NoIaSP*@TkK#L<9?4P`^Rks=CQ zLs&N4W^8okr^jzt1z5C?$7>WsQKSiz0nG#z^4+vc5U7X);+1BRwzvMA2_%#vgZ^*x zmS0|fmb1b?>thVgMDab@8jZq9adHZ0COOX%3tcTZbtAxi^tt4_Zi2Ajz|;^t<#1;E2x;ZU+ANGYa<{5rmp zL64Bnhnsv<*1!o9E6bW5I#$P|5k@v>iu4GTHt`(@3o=V*w?oY zLGLhY$C4K+2dB6Hbbm33wd&~g&Hdn^4UQ{c*otOJ|GP11vm0yhQE<$js^PZ&AZT+* zHMHoZ>ECN<%3F$*v6JF9lV_&d&Xy6DWr?XU*4cYRK7#QG*Kbq8V3Eo51uiU5>a&vq zqQaYx;-(=GEecT-8W|E6`8R%LBS(wg0jwJWGC+UGYwcVZM^P>I95ruNM&3h>M=&eA zGm2?WYc;425BvWZ{MF}7u#5ADl0YhI2QFLDQxNU!F25>Y# zAv^~t3sN$lXDgx0(~VD7GB2L#{V2R&^l3oU?9b00X`LIb{K>C~T76^$!Gx3h_BL4& ze!@IwaIjY?x_O}rM|1lp%Fl4a9BvJc@sNAPg(9vAn<1lT6Y8&!sZg1kDHsfzME@o>&RU zBO#?R4;Kng>~#$q0HmB|S^N<8)-ojJTFyFSZTWKm4FA~IjdDSUln~Mf450d zc)OXuKB7Kj(NSm48)Kwz1QfZv=xkaFfbC6eF`{*6Ttw ztK(=4HK|>YOI?Qcctp&$;!0eNSbOQ-i zMPbEXXY6-$Mt=}n1pWG@fRV$^=FT=Kw{enZCczuDqfED-XXW*^x7WVGWs26BrbwP> z{v8fqgHil<<`KoaqVP7o(rK#}Z#2iSle_$0bgPYCgalg=_{7%og*_Zw`*od!ue77w zBW@szC+Zu#qCpSxNn7`#hv7D3y6m}e{?n;qV|tCoH~s9t6Cn+rUAa=OUztY+P2q)w ztQN{yFYI@(yxuqxrBYLN{+mphE9K;5B?o9Cp{QUlrracQ4PLQpopg5ekhIvmAMXix zUecd49K19K!<31*`ow3O_zun(Zat&fN`@|4vr(2L?U2iGQ^T@VaoeSYrV=gBi9b8r z>%kn9M|oH#8i9%u*GgEoAPo=gqYRkg5EU6Ft6%=+8Mwx2Uw)mR%pkW~Y4u^XdY~qh zUkRQV2~4nRR9?5bD#Ue4!0VJ5)_z}Nmv-ZGL@O_Hbo-TzWT(h5EsHdb1M8;QyD&ay zXz$J!*c3I2g?Y3X2%;43IbNC9WWrv34{0vJ(82d*Bg6^e}PI)Pe?$Y zWn|*!oB0^V7bRxne%lJ0r7n6o_edYoP)}^86WfAR>L&vU49{(pSSw$WRvuH0m|xdXa5UYJ_kF_J}LJW<-ZCb?*} z`nl^{5Ced*^TMOrN3z?=Md-&5gFj?rob$eyMbwlXn0oCMz|jCgqfiIK{)ac~oz0)K zsg)2_Q=qC9Ox%3~x=Ki_F{1&A8cd{uo4`fG}W$_tv?JauMa|o1Xe_>Oo=s`T8@A2 z&)d<)7|gE-3+$4JKe%$T4r#X&!8$~K($ux16I9z3h38SB_ctv>@^0%qI*6w;C9$EB zW4nKBnN?}+n;bE2ty`aaW5+AV3k%UmN6xyM0?`fRTM@J4EwptN1Y^Q&W9xeEd2~#mc_hl&0pVU7F>#2`Gv!>*->dMzc}v$DnchlPj%0 zXyeEIZ|^a|2ofR!LAVMmrLUx<>N8<`o1N{fHkC0%dNVJ&-zW`S60*E#Ropg3&FG%G`6_KM6;k4=lCm-gwHksUCbPRvb$_ZtKeW#gub61J{zlWF*kRx6 zxE!^;GxFLHy}q1eWlhuQn^QjPuHCWb+Cklu#v4akpkyX6oS0Q5$zotAMkK4KG{FNy zflIq&{=z>|QiM8f9Zh;FIKy~`{z}W}FgVn-aY02zSs`b{=hsf8`s5(zV{(G6RQU#yW6IFJ|_@} z6ud5$R$!^b4XWXIKyHx7>2U3kMwNzn&%v0f0 zadlQ)BM0y;`avvK$>=%p*l&$-9XU4lCxf^@b6Gs8&}W@N(mP%_6>N{kQ{!H5ugUi6 zEa=CR+A!_i@i^iRVOp(K>=9+0!AQ4_>K9(`4?AAW`DH0pI4M990JuAM4GFrn7nr_f zpv+>Ei@57m#H2+IeVKeF$2)0eZqq@_<*Mz4)z&yftnI(H-G|9Rt%oZ`tS>x0ee;Cy zd9ytsv}k<#0axkg!hs(GbkL1vrTxqKd4yEu)wPNNI04i|_H&0)ef-hEckpnyBPze0 z){`;k#PQF2(h&9Jn5}o5ADdj0y*J!(mPE96rysHFa1XhCz7dZlb*F(VwFg2Z#XLX{ZMo?2 zJQGmO-(HE_2^8Nk;@C5|Us>>!UWI@E<8$`R%}Y@03Vd^0|cfjGj#grct`I z*r|d2E5Xb@Qsiej>i!qbo&DR8Fu%--D` z>$Byu;mGk~@zp^0jf5HRAn*MZJ}Z>^z5L6VRZ$q)7*XI0V-|PAi-^eq=a! z=)9?`K@q|6BwCToRZrL{;4gWteq#Uz&Kj)H>6?|8Cuy+^gQ*<*Y3&HZgMeilU`XF) z=jl`)GwaeawdUJs>`;vK4wv220^WCGGZMrj-YHF^Vjrbu8RrDTYlXZ%sQ9>|d`>p` z!IKD6+-?fbN>9sJS=hX4z4~*%1IjNR!(!~+KC=WND(L`4ORT9h6BT{b zOj}sSn?33$0PYLE3I$}A>P+6+qzVgJak=_syntbvct7V~Z4(Z85B%pnXDd6|pgY*Q zkIw}Xtj`(P%sAX(lZ}P5ob8ZSLfO)ucWTO#U88ofEgIIKZ^0z8YSt-l>AX|ZYdaqs z_Ec3Np)e7(iq*RCvx;iapM6$d(!^$E_KxY_@2`oT+&@h^ORl>Y00ddu&somY!(gEP zqv`LVTheo9x@vvW%F%rjdOl(a)nYqZYzG@UsTKqn=r>Ovguf@FMIh@ zMB(MUDYKMtDe0Q-m2-*oz^Nzp@tSwy#j=IL+-xF|xYB&xyZ%v$1gw6EQelzm_}_JX z8G#t|`n-b=6Ya@^9^zxH2TkA5UKdO!1W*7tNJ=cZFW@fzAs8toKZfK_li9QmlHKKj zvNlAmw%`gumd>~BbTTR}IoKED0J+7&$K&iR_&~fq`jSVz2qxIq5c>8{Ysk{?d)nV? z?S3RY_Mr%Q+0yUWPGRTMG9_ERUM^a6g; zbpaVisJ0Aqnj5!&N3YBccd9S6yl)lYVCU49YSnIj5IfZ79*Saj1<1 zzP8p?(UfZ&-At+Ecyi#F-R$=_y>yYZ{7tK^-ro;yPu4=+Q>Y&#^E}o?P!roo&X19L zvI~!YiIoRm;RflyHvrHH!JzW)@DWbI%%u@)%sX((>-*H5hqhS9phsTh>l41aYr(+9 zj_WBOp;~2Rc`k7qsVJP)VUZEvcZd*d98ta=OWb7!8#7rY)#?^}R)ye{Kl-#uz8~H_ z(yzQ2h9Z0^JY{Lgm`oQK2!|NRB2U*b=NV=i^|4*<1si(T&xY{V%jiVY!=!Abpr`Y^ zRz-(n`D0?go0)MYLZc~TUbI!)3Xp*doblS+rLjrO@NGh)o8lc=Sur@uHMAe)lXv&F z(%KLGx@Pu0EBbNbpq^%VMKEK{;apWWU+PN^g{L)kd=a9Kh@d#XxMZr^=?y*cJfEM& z6Y*>31l-IOQ-V*wR*cV=E0X}~e*Lb4cI#*@;?XFo33f^|>kngchxn^~PS{v7wQ%n@ zVLoN}rlx~~SM@LhUz;t@tyyOuQxGqj5y)?e864We&lrKK=*m-H$b?JfwzrSWz}X+)Se=0gP-8~JWwEFe{V$Xcjw{7(5(7#VqgTv$Fhnuo=J{;vn2ZGa# zTRH{4Nu+7?%?iE^44Pj86NOnW4Ja0g-tFzAZ=D#zkHt z!)BFFvYy-6%7<6ZUE2vuOK{F39M@|%TP`tXh$~`{ppAhP4Dm<(lKF+-Cg8~Xsn5Re z=CsM}uzbaUn@~;^k?lxa_xMgcu3eZQA7ew<>qn1hW&aik z8VwG&UMnxS-zL+wq8eV^^ysZXPTI@CcvABtc%>csZ@*xHdV_kJ8O=}dF;&?1)(!O$ z_N-jI^xlgOOyA0A;8c=vZ?63;kwoD&+iKvh=l#1I~r~e!D8A?YMN$$ z^b?MN;x7F%r*Tsp{UKiPK&2G-JmEPR2`qfbsjn+RICn03TSvg;dv8rL=*WB^W#hL$ zZGl&DiETjVh7qG6f7-Dj=)%qykZqL8X3EWUq@2Rz zUg54p$kg|RfyPgwBd2}>#N&T>vf>-oT+)+0W{I|+Y`@YcQnCd8QS>2lhp07!y^ zp-YnzOH#jWn@aw5ourUbgURFUfRPrvlR0wFU>{Y3At8rpQs{~%tEb5-77 zL#ny>wnU*=vIO8YaQ!&BKvbPMNuA5JKGa@-z!;*SKqeVn_u@O&>_=mu)aiJ)`WXIm zJQ@KBT)+AG(vdoX$e2 z#>Ck38Cet7Gx`-rn*`AmucFgji%_bKar9NHw-Mx&!LLuuikB?EOkf+7E&l$EW@mBV zp$8PD4~{w=$CR+u4|ayt!X8~ie9x)yo97_)ZhTD0IO;Ex;JCv#6LgKn8z?i=2kCDY zR{VP6jKX3Dz2C~Q8HbN*r>$#e+1c}@g^DnhJF`~&9*`IroOs}FSeR+iVEpln=FG}a z`!KPxs|Lyq9THd~8I#LoFeulqV;U_CF?lTj+Y{ibC%yW8oG_ft#g9n0bG@dj#IdYff zR%-O5bG_`efcHH%;fq>y%4O;R$5m({(4>~|I{MF)YN9SSu)Z)d6vC*gUa?D=e+e?5 zi)y>y@&y^tO zH^1TnV91BpA60fBLo})RH1)3!q;`utI2_cgfhIJN(oMgtY>IUeLL(j>kfcsEpfk|h z*b(p1r<8vyn`h-_oA`e{Rz`VW!lc8~)dG8VZh+d)bjdw!=I}urUG>MeV_mjC&N(4r zgo#+Sp}J$>fP&8CzdV4xzr?42WmifQj|sR)nu@$4LUre9Vt2^LjU+}8GAmcz&m{}> zYJhaFh2Vu#ArWfehZB}(_l+eUlk%+Qbx&Mk>o|uED1v|Y?Ck!~v>W^fz8|iJt&r*e z3d_tDCt3@A8SNoeF;;soUANS{*22sDwVCX2u9)e(9eaR&($&O_(|4B*VX(nHzJ?Y4 z+&-RWpJSiWai-wR@KfJ3lsOHl#z_?^X~FB;#6n! z+kXAWCSn>BX#HSK%oHyc125rnkE)X22k$pYAwQ-iEn;5uTdD(@*Xz}ZCOotI6n4q4 z&kWf!Qjf{;i`L6IdTqI4V|7rM4szha;=iQ^Lz+;v5G^G0GV&I@!aaKn_>Cc`@w^Az zF8ybU42o<!k;-q^ct0^JZ0)bWHK!g`-@wy&F~|uHdq4krWt^^;_B}ijP|%;Q(XT_8-Dh2 zBp-$}Y3^tAa6@2&CUup)G)<6}N{CsIan=^Wbf^5>3!*9pdIyhiIKm^2JaI_ggHIBN z7#eE;z7Hh!`HOmMURyDdQZ6xaAl_!Qb~Gef$R%OF^PHlO@XO zm!n^4mU+uSw6jXKkxTH2jw7$^opVBO6OGX;$ceMq`g~^pp%SwA$ksl@YOP1P^E9Gz z0=gjzK%KH*wO?7Q*wpvzqpYR)r)}Q9^eJSz0#V%Ct;@?23u%k{nK`%HKl@A%Hg8Wb zS_@ytJjy-+hX9m(00lrsNxjtiIzJdYdqX@%P3wEhV=)QuDT_n9OO4T*>Owhe@oq*# zm3gSdF8@I|87xdtAY~;6<9O4EFL5flY-#!XkP-XkbVG7ZF}dlwJ`?L_wM+q*M0oL7 z`0-C(>-B*XffcRuQ~4>R7-K&1S#A?nhh|lAt!;O<%odNB#q#F@PTB4J5*8Lb;SV$C z=G+`<(TThc-tF6;+feI#-&PqTyn9uvzUK=b&DKPKs9=xv9xhZf8jqSCj;Vj6pg0l? z8vd&feOR%`T*8tGXYJ@0-YI0NwXU>ELQ0Q7vit8Mj`ADmKLOn#DVT=FZ}3{R=Pga- zlx&5{_RKwONZOCBu{qbjrdRI8Pn72y)3)ZLgcU97^=;p94xC0@E3L=C%tTZ~SX&nV zFb=+H(xC|rJ2zA=P-PLq3LevzZ@kJw@mtR^R-Ym<_M42 zb~n5rL$+yyuacE3-5Y~PBS-l&$hrNvic`s`G~wk$u|eNsF=PIg%lwg`T3<~Wiu>_z zSOJxw?_65YBYanB%BdAyY*SR~pLB56^d-gnNJ`=UbnMBN2_+b|&utyS#*<|UTMD}q zi_gu{3TRB${x<0fLEXhSVie%v}{-GL_kwwdFh3 zyh57wY@_`vx-&$4sl1KBe)gR@!mGQ)KNEx?F7h53C1rG1Gw>!=V0Ne3K`;mDpC(I6 zttXVa*`)0TQl@f-X%3Av&-b}wge-_*^WZudsf3{mA(mlK_^00%2lk8dVW+Nb-{Kkp z_hAs%2|^mE4{u>8`gHjX*d6GoNG_B7&2%a+Q!FB$os^YTDJ#bWxy7|QaxLpcNR#jI z4=Vlfzy6_7_18~ZorgVeuYkPZ zd;=g;$r1oMt1bXM*Zyb(!4uhH`hbL?6e-lk0sYDq`nUc?_^^L+PCYgUVw)8*Ux2j)tk`N zHHbHj|M4ppQ=iAIEiA=4eyv|aIMg<1T}8#ccwvBqqxgr_62~`=Ibiolr@y}L>DjY% z=}Cy#)@`cT;5t!O^M$0@&2V`2!fE~*bXY`4H99bkM{tN4qAT*}%wFTgR4G)F~b0VDOz*9p+z2EcxUP&E$7fMz}w*6)J`>*5=_$X``VDqj5J263B|f z)2{f82@gmO#z*+0(#UD#V&@9dfw8Vl-PNzp)%q&J1RzD|Gj8|u-O69;kI-HX8m60T z?ii}7S|05-jU~ELo<6>jO0R3a{?_7Df8E2=z;$|v9#u_egP(sh+FWxtt@p@Mt6>;t z+*?CC{m=rlDsEWB73M!(xWAGRe*0BC-4U1~u$&f6RHP2X ze6SjubY=K^uq79+0_N)+;lZu5{tGLC1v>NF-}Wh$?KV-hZw5$f(~(NpQXUN+CUzdb zC$b8FerNQDc)DMp;8uDxlvt(XOfS5(SJYiCHJincEpmisBzgo5=7_gXkCbE>IHGb?GTRpY@T7S2GdzOG2g*8q7P7 z{wh9g*+o-(+mA~EjUM+s|DHQ&zc}0r8Le327rZ_|3xF zXp?w_uzwOuM=W8@rf(*P%tQhr8K$RKTvL3%jcr>Q3Cb%(* z?$cft^NLkvJoAQAp^v~>-nMiChve#NLb|}-sOV){k*E4a+qC`xG)`Qdc=LN`{14}z z4_$2N&}vT~<#utyXl2s-&%)D|jnDHg7Br19-t?>j4LRvg7W`J+hDU#N4N;1E+heRb@N;)YhOY44*yKzc1asnP<-Kw6ZP-4L&I+ZPt8E)uWe?2?SySFo5X z%lqN%r-_-uyXFpqsrCfTh3i7EBau#bYx}kIUbTLKH7f~=^(!cIM9T{6Zn{GX$1YU8 zLQHR`o}1cXa*j#2YK&Ay*~(U&Ogk;$?^)!u(^6iTJe(`&tNs>_8{E6^gwkd=hA*2S z+dEUz@queRxb}x`{kt#|@oI?YEEjB<&As?_JY7i5t3?5Esa2d@E4wh8ciHpvNy9OKQ;aB?aXph35d@ z80c+K1#D?<4*(wm3z}c!{cTp_Nb#SP*SQOx!377Kp1~N_ej;ey*|Pc*q|$U%?cXj&eNQuqnzUn`33#Fq*)C^ZD!$m-k)n(~r$gJu%OI=5f6p zkH});mlEHRAf&3)6EDH1)!j~Un7UFs#ILA;GF2>QvIx(lAdACqxyM6hS4xXvpNX>8 zc_8nNrl3fI6Y|Y{bWh>Ys4d)k{l$fu(cY>53iQ!ujvT5p|96*GR!N@WG;nU^AmpYw zAh;h;OtXeJx$@fHPEmB@IN!8j1=xnH(R{UT66dOTNzf<-&W9n~!! z0Z1jOpIa$!77J>6JMCjvX}9t(7NKCIL6qx!xH=wQ^x2a^pmR9uGcoZl$fK;@UOEa< znLJD^&l#ERxT@}1my7k#&3~n!e$09=;k>r_JprE;dZtrCwD#7CTNO;2w2LeOJBYzmwf0Oxn$0Jw#QWJ^)taL2UHCBZ?$iid zud;E{5{Qi&O=BAUUS?83OW#82A+b0n9K>d=iY_0t0G0&n^T(H8BzQgWlnV@5&yNT5VPosC2r855r!=#<)_%DifPZH4Hf zyjdAc;-+MHW`TK>5)`}=ewn0YQF-6Ovg?w`Gq1?9ki*5gCK_P~`O6<(U>w>X@x~lo z9iyYv%-$zi@h(``x21zmvFxUmCo3zab9+b&YP)O7Sj=rJ<8kOH&rh`LK5AN18F6Fv zvyWHlT@9da(f18&d~{_S>#D_X+;_=^&QLHAh7}Z+)f+KSt_y&QjvDg*Eh|7#|#TR^??9I+uSjxs_b z&+(kHm}6rO%oyX)X#~=IGz;SA?_AGb;y4l#n%(?V$~+0t>m3zG9*L&S$z3=g0MS86 zoLcmfpXK-2SJL%iB zm6Bix2_P;d0a_n#E_ym${08H5Iuwk+0HV>q$`3XIiyJxZ{GEfkOoLk_MV%xxZW!2b z^RpJKZAOI_eUqFeb3|#v?fJJ5|HRrV{Ia47(FiQ2pYc{(K5U;?iIQfFD_-oYVEl{XQo_Pcs-Wr;B78Z+*H)-61k(`lQ3+& z^>Ha%Ve?%XzHJ0mw7}$;PBEMh+dCAush`Em2^~@ zg>Xkfg5;a!ubplG;aJ*fbwg($zKuOkj<)roik9g3_DKlTykK;O3v z;{5PTqDwaLUVBXr_vFcb@kz4p2X)>#nvW=c!zEAp@n7+?4qp zIYGVM%Sj}8+gR+p%dTKL^d(D=o3<$}f-mhRnuMU;CLo z*~mxLE##LKbw|$arlqOSZrM@rU1l8$LEdj!)zFJhzPeegfI|*VeZq{Shk*VdmZyX5 zx6xDujG#Fw_7_kM7|^3o)(b!x=NQ+qZ1{8%ztg7>)CiRuzsZu&_FJ&;SDm9z?X#9_&)L;S4zvI~mbHISxnM~C zys!{ED5#+|G)z&>(AdgR)=Tk-FvuxPN0_uT^CATe2ttz8<)#A(=*8dU0$It`Ur_?h za)k3@fPYD!A0)OjFE?+GNQF7iRWZZqePVQW{8UtNTa{VSjB`AlKBy?Sh4|v8$ij{5 z)6(6rxq^dK3#$|&Al+URUzaLyC_^7hNT$b|P;1jEqOO=$J8tMtXhgajj%MwZ^ktY> zJqYBS?G8}<$Z`~OGa&SC<8>hZbo;O1)Ua7}V$+37^bWc{v_BC()D}y76Q%s;Lc{hA zi)YsLYz#W;dB#Wd0XBeu!OW4|shWr{hZ_Rg8A3xQNlfe)?NKn-GR zKYgve-bYM^0EpZ~bL8vVzv0K(*WdX@?RekgbNiJF7@7m?nLr9Ad+bqN=I|x>*hB1~ z?BTbBh+hpH7lO`EySrF@Wj(};bfoKnir2z>;o-E<{42VeZqx7&`Nz^x<+hywrEi;v z&wd3(c#AYfp9jCGeQ<7YT^3GE%9J5I!LzT~RN71d1>G9^Cji}#<*EuzyN^l?)^{F18n`dT`txxdq=(Xe@| z-cmYiL-4IJmx@zWK^yFZTubX-#qEP_t{}QcToX>OpV3)+QDn(JO$<~ea=cJpK>`bF zZ}{h}3XX_gr-Z~E5DE(=shfXCNmJIg!9ck+bEJ7*r_GG}Lk1G@tDR>1%G$5b;ol{b zS5GeHw6jP*Wey4EZ+CrQ1C=UYaVAu8gilpeW3)9Ylw8W^!4OI6{}$X7Vk~S%x?V`& zQXJCf)pKmUQ_^s?j8QYQj%uqepjlTqr=ooh)(rf1HmZUK#ZX3qBR#&yC*XFVY**T7vkYG5GDb5sYjQ{Yfl#0CBn;Z6UA z?9pnD{XdU$o*7h7ft`CoP$(D9_L-4Fo+G&`<`Rp?nlgnoJOO>z zbP^z>vzz%B1-VWJT#T?V-GAIr{!_{GU-K28C5-Hwg|(`GrX8v=NB^3=v5gXYa-vGM zfz3u(9|){8`n-ha(^S`HAC`qFxUN4pxE#(pI0dABg$Iq-B;`LRGTPG;QHfL(C( zaLWP9KqKp(%4gkoHdjv5i4t?D1tb*v`+wI2C_S3!0#gr>F73rEPb;bxK_V?|n)fU9 z1A|;RCT$qDl8$8RkM=*OC{vLSDHRU-3f7Gl@l@{)1yaM{1UUjqj$jP_z_9dRIVx){ z)biC_MFGHvbNU7TgOUG@WXE>B_gaYkdV6^OnkjPi3t3N1Av1YIJ6nX6r;BlHMf1pw z8LpZF?24i;L3dp$A$QG3O?Y{D@#4Zd7)(S12jp=iT1~BRf-tvDICpl7juBGk?xJI8}kIo7XXfnSgy`+7S?+u`vSvJr5`32yLDsvky@`GOxsm zx|30#^ZZ(3P@nF!DxMl?B3MAG?j(%AUIl1h1fZnc?L9>F4|--)MSZjR-hr>gLgHA* zAq4Fu7&bZTu=UxoAwb`5+Hk6U|3&lXk~o-YJrTeY#95#Jf3;Qk3wrJ`9fewC!PknX zNwO&%fN7b_>nu4b`wH{GGx$C59!hW%1!C}QxwdtDU_mQLvnf^pjPPp&4=D2nZgj*0 zssND4N=$VwrRAZ^V?rRY`De$@;aWoVU{18ZDn;N!e+QxbokQxyoCXQGl-*9w4UYt( zQd(~(O0~G~G>|-lm;U0OBHtN>w?>IuS9vt9-W)Ir3ko8X=GlYnz-lb(JDk@cd4&sQ zq_4$hEky6N)#4-$G(BR(b$J^FG{lW{DY65p!*Bz(u=qj69U(V_9$17lRzuY%VzY6; zw=iorP>n^Y_Lpx*eb*0w*_}kAMY&>m^RtI)rP)^!)Xc_6HZELs1h1;>CuOLCrSKCF z=~;&&nDP?sj}QaJNOQYBSW8MAngoMPBO!RS#MrG^K;-mp=J`1-&B~(U=w~rct}mG% zI={C4OJaw|Gav>NfDCA|;k(0+hOl#zgh?prO>INB6G!6*9B654r*dg^uPDrXnoru6 zhFNh;3CQT5zlsuT!P!=}iI0@IpZT_-QWZ^lv{imSIFGtABUrI%LvmL=&SCmd|EQ?? zs*>7`IUTQGJ^bYG6^(scdLv=3-<5NlvO+k#Pg!3C{z}7*Yd_jtuaP;O2y)irxj7eD%(0bIO4LYd=#mn&NN9LChIQSS_C4Ij!o{^h;BG zKTI{%JHRqn3wS2#`&&`$%D`m(mw|P#bXfQdk3r|f&RBZH{Ym>;4xD&$NsMKI{WG5A zr%ro@WL8>x&B|KDSZV$^ig(PE3xCmAfypyPd^rEgTKc}eMuZ#-9!RoARS3NQW?9wo zQ#+%`a5}m}Zv^2PMeKbD%0JU+E%3>YXADLuPc?1UeVuVsE*-5b5S!7>SpQ^f@F5$J zz{`c0-ILAz3lf2$RsyLOC&k@t)vedtDoP!Czgwh3!VjyTjjD9z9Lr7^zg^8L9}T@z z$iST%Ko>dl*%ARQEiE~~a}rH{V1T!;@r=C-`)1{vC)s_#-I8Ts=M|I5$=J9f^jCKB zGP$@1Pawb=LodFb+kYlBlpX$q&PS>QtF}g-b_l_#?G3k;#0e3($+#lN;YaHDO=X~R zT}b5|vaR=|q2%AF{DwxM5aR!bNBqqtM2a+HN}=SZxuuRpvb3(clio@=rL7<=$t&Sh zB4E2vFCzXcOS=uqJum76g4zlO@ro>=H?qID{~)~^E)x!|&`WA?)VOjmo7++2LjRRg z)^yVKx!vKZ;U}?n%S)3}4jA@@)nPUTAhSGtj0Gyj3EQt>ga)qCJcikq4vt05XVX=t zu`6wsB-k_DP=hSMBuLvRq3_=~dZ7eUp4shkHTfC5u%`a-LDA-oIa$7{pXaXb6Q*{? zJxTcElC-p8WIPTWB<;uW6C(m@oeeNW7Z{S4X7L({`@2Kt!cPmHI?03`j44T0R3ADz ztPbV5WNca4S__W#?NFGc5U~lw-+-w$}lE=kYD;CO@kpemM>AEJ>Jehn#Xu zmXgxW_x6H0q6Vr$fB zkcq<~jovCBKg;CCK%2!!3|Pgi@FgH(SBZ7fSru6P{!7=ez=UJ77{()>R*) z2tfb<3I9i^tigN>?$eV3_XXDG1`2@Lf&B)E;_m<<7V!FCbJ3M|fT}9XU}jcj`ya3a zz9tozXmUIl#&`?1Q*HC$@FU*;{2Z{kC_lK*k;VuLPuu0Ma+>WxD3W}MflC5j|8Zm< zQ2RH7ZTxpd)RK6S5g>IOK(pn{3VR*~`wo3xxKQxy&bG9X9o=-JEA<>L!jQ&k4W~2X zkM!lN4p%J|AX`;ab)7#DjKCV&?T(#bEUl>)zqp?};zJh(zF`xgW+N7fWgoEPTIJy= zUVeaF&sho$oEulvj?(sl;w5<&ShGG|3M9-$hTPgKjCjD=l_k6wTiR_L4sftfBl}35 zdVpM_Fa>qtws?cB1thi$w<-Yn1*cW&^+%ADlaF}c0eDDpV#2=@fC0=`(Q;U; z%DYUrw;y~Ce>hYdY#EU>*I~07F_thA=G=M|u!lTBOMG*Q3En8Jf2|eeI_(?`tMs3MFP{Dm0Wsdp+!uye3SeLqQV?`2Cx0#$=dt$PK7Dsjr#(*pWS$-; zD}j^VvNrmFIXC8aZyXy)3yXl(-G$B5JsNL-1NOqu3O22FR_ zx11^}(W_r}-YOw0llJRs)sN>m;210+8o%moVk7xFs)EX9Q??h-hqxjnk4ZCWH_)WR z3-EVVdBSG%-G$L*6eU3RPP;Xa)dCm)Ih5L)UH;##3E=+Kn!rybJ>YNZjfBCZB!~IM zQz$zrBo2|A>`hczQ^oG$k!RJnKPb7KQ>VU?=vEU*_pbe^0lvLBGFqQXqlCW!W3peY z{du~!{quN@058f!Z(8-GPC^+-pC19?53Cr?mlZaPav71u_pH_sm%8>tjL{o}bIBoT z6mCK+y&j2#5w%;RPoR-e5qRG6L_w(`@rL7+A|zT+9DgR1<1`pa4!K0R3>UqyLkWQ! z?YybFN+vSuuL=r^07q>?tt8-!W>?zL@4jd}OXdqu%;?Y`r5pmpEwZ|NLI6+cbUXb0 zp1WXfCi2onjt28BJThu1rxZUaurTb9KLGnL!NmR|pwz#?FD0}sME!ud+^FuoLAr~o z5ShY71oed>RL812y7)aCJq(ZDY2VP~dYbRM4CKqoA<<}rhaU&22pSdE2?M(Y+uO+_ z!FJbwpwoGDoBZJkXQJ8kR}+dXj$O@wtW8+Tlqf^@xSH0{|&~4g>(M*KQpUc;hOPQ>KhZe7&|9%m1@U>vBHiR6jfmes640V)6Y8-%Xds@va#pM!?&bOD6N; z^i9eTaJ2YL;;G0_EQPwR(|>-y9<~z`_h%wi6wjF7*3xD{d>tFa`4V8Ah;fu6nb()q z1ztuqaFV`bzk`n;iMD*za*v>KAXy5VR+o;;&!bs?~1&+b6c|6Cuz#U(sg^%%>CRtnc;U#J@m#0i+>2E*S$io0MV+q<;)YdfvzNfDOIiA+ozB~fc>sf}y?vyA}R zM!)*8n8#s(ia=X05!@n6=e2TrqD&T?~F zC=OByMf|?u6y=6Ubk=Nr%R&4@iOLbkO0`9Th@sgZ7#NWgitB#)Cr2pGuI_5d?Tx+| z!Z^F!E>fU7nC9rYFr?dMk#^P8Rwu3eF=TTBuDRxuU;Q=v-JNK8&_*tm#PN7M5V@)q zjpYYKfj9s3M6Be*UHYV!y6bBrwTtheF-VM;K3`MIy8%i8+~IqB!Rbn1HBUBrNPC*f zs>FpN!ZDbI-myYLhOTgmj*S$wTS!LiXhjP@nhS&ISnj`!L)&=T)PWZK&U2x)^)6!LfeBDIs`K8lT>GlV43sGg; zNoC7i6H54_9tV9UvDI!r*YOx40BYrgL6gwM?-?&=Ci-6-uF`>|%7zoJ!kCR3K@vC} z8cGOz+Ev>PHnTYtzD4sNw&ob)_k#7OSI9M;SrfJAnOMzTKL;Ws@Z2RhpR~YScoi?g z1%N2chwlsYljy>CGzRgmY8}ZZRs+JfT=?`n(cfLEg^V zOpn=HkF$s^c@9}9X9S(w6GWy?efvJf5n1iYKMYdPMWwqVn>@+Mv=GkH2=jH5_b*jn zxgUn&`stUa0DO|9<#jn_!78?Rt~sE0r_TL2IeZdnl z+V)+b&FrGlA`GvI^Ygnof1~{lQMBny~ax(w_$s<==kI6*5>eNv%wacCxIHV%k5N| zrVgAH*slwea_rI=JwNQXXyKQ0S;`lZksE{xh#HZj;d5hFp6_r(Xw&Y-KM(Bz7mJ(2 z)9iLf+EebzkD8Kl@1BKRE_41ZX*P&oqXKV5*;DPZ{GaZwGpNaQ-|Ma}?onXqs6Y?| z1f;isbU_R~bb}O)fFM1D7P`_|nt)VAI!Ff?LpvYslFvIEtb>! zSqrkzqDMprDh(s8DYa?kJCjb$w3JT<(U!MZtSd%KnaM7kaxuWi0TO4$DTO~o6!^=2^D zjoy^AfCR96TA4-m46liCkL|mtSw`} zTJ40Xg|S(5MEL^tfM>pA@EOfgv+!l1Oz24_{p>D}I(BT-9rj8|=mS=dR~M=v#=)l5 z8zFR2LDBix3yZU2K@rz0)!5;fx!mROpA)1J%&kIl-HNL%))HV3d^e80!#=7QmfIkN zpZSkUTaC3fAI;Kc3c+1BrF_6R-QVo$P3y8o_hF!q(8N7#vgEArbk!smCciS!xA6EP zG%ZB{6-t;?q{m6h4{+qyMbhE-Q%d7qs~8%@Fjwd}Yp?UX+z&7G9j-s&_{okO&Jm~R z&CwHSHx>Hh5Lo!Zc6Kk|Vp?iI59`gxFpPzp@WOVomtExl*ShXfA6rig6hwS5PwWVTJ^%v?4M1Tk0 z`e8nFrM&OKPHah^|L|&p$)fQIlk~vaC*_(9mk);(c~S+f-KM@qd!*l|QQ}?d%7I2B zLuVkzIjE=JwD9Y(NB(;ZDh?U3(C$cBd5(>Xt@|Xcnc_8y$2%pP5Bl934C8%)|?pBl3z+Jp)UHAs3MDQ_P@KnMmd?FD&P)SEX{SE82w zNn|s*x5r(nt&${eq$~o$${0%+XepRjnY^OW$q-KNw&{JnrbZy??vmYc2l8;pikyJqTQ=_a31EvRs%Je8bW_ox){YzLo zYf<7x9}J9aR7vP|JhzBCULYM?&?N3*AyQV5rE9GAIl)dbsUP`@g*`{!=WRK!wms)+ zORms?ktrp8qTd%BG*z)Yg~f7 zj!N%SY%fFKCP_9@v}2Q`OLu83(~w%RHr~Gsc#&P2X&puS)$|S9k(jflk&!$OyJzCE zl}~r-H*<1GN7sG%ir#MilIKNM9Nc+$rI;Zd%H_K!5-i0G@0p!l4u9iPLRH){z`nPU zd}rVGeDbf?HUFkIVHTa{JlTDx+b^z~Bwk}@rvp#Esi*gcqqe?xorkkt*|)jQ_7uN# zm2S4}(?@(6#KguA5{GkX+hs4O0PqW+M#fzIr0IR~v%SMf*OLjJGZl~9w@Z7`N8|6g z%(M^(_Q4#L;$I8qnD8s-fjSq9Kt5JZa3s;=(eD5-sW6MVH?3djG!R4&UxI z{uIJ;Ur&JXEuCD3adnXjEh={G_*BBhB;wwls4PA!llUbDc`Bz<*4N-XGyJT4=2e_h zR75Rww~q*k+2wYS!$K)j2y;DEQGqXhWrE{&g*7F|V=|%c{lhewM7{>y>8}@|UX;sa z^bye(mxtw5=$NwAKGdMtYurm4>61<=wsq*(ALh4NBtS~rXBiFyV@7uGkUVCGE&W^U z*o%PnGe+w=jWZLUE@Ob0i46A#n~1wgbcGt`+f!?0>gWo=*Ylp`CKUU)Jc8Au&d?>P zU~>v0We|Oe`}d^xlii>GaCBv|U8_a-BHYj6Q0cw32qPn7c?_j~jC`M!gYv{?|3b}Nr8`9n%aVs+cUd9)lOa>LUmKl~Ou9SeucL_aI9c0<@u zzy&JCbc@IQQ@G9aTQWNhpVo0@`Y|4kMS-9>ZGNF#4~QFcWZf5E*ko_gNl@W4Rt-oDISWuvlfxPbPb;pC{C>O1 zd*VGjq>(n{P`kupnTU+`*7qB<6n%<18sMirM@gNJ;((+Ap)**R{rRTvpe27AYJOxo z6g%)ltcTNQ{7zbl%r`$0XSwC;!K*KofP5+sJ3H}$&0hCeghCZt&-}j6?**)h+Shoc zm}uy@I{wI#({n;m%QA610*g8qlMMI)Jaf(k$y`q|LZ><-=KNxwnT(JIZQ5aOR$U>z z?@(M#7DFx_VLWrqN9$ynDiX8^^c+z~pxOV{a##mqX}eKJG8%JMFBpZ?X3-1Ul~`sf z2ll5-_kUdXYWK&Awe}vl6^|?yN(Y*z_^<{g0j_60P8WXp2ByI{yUS{}0QD z|Msu(5!Y?5I!mrIY%YB{%e0&wBEdz!OI}18mA+{3UR2QE3xJZz9^(yup9>h_=CZlL z!2zp7Y|~6q4tIerfIk(1A8%aW-fRur-g@TTO%yiQb-Gt>!&Fs>M(C;~HQ$jcGPs_D zi^Zdh^@PweOp@=ok6+Kbwi+YdFjW&un+|;a-NmmPpAvM1_&SgY%3%wo#_;{ELin$| z*eaJ|J~UzoqwMKcnn6Fm6zwy$kg>HLf%YJYS*sgJPsLwbYR5PXW}5KK#jq*?bnL$ra{<3#y&c4a4&=B#LcS~Cv)}51S1w|D3ysJ=WHtF-lX@r%rj1LRE!K(QnY4qD1Q39t&L{4}WuMM$352q%r>x`fk={N% z{!*FozsGnLILS@0WYGJ&qrPdgysEst^;hG@Ix3x+^w8Nrq>Q&ENv$VIQSZ$}SRMDU z#>H(GM#r3Kgg1lE&UHjQ!bF)TPgH1p%b(hpaur;|(0axgDiFoB|5RsgVxRkvdq<4o zFNersMjU1ksyFS{=0b9B=8PZpZ#O{yMj*@#URT4%Qf8WEOieBD3*M-`?|T7ravD%i z4q+awALu&jx)>5~RwtVw`we63b8Rkx?T26!MKzuOHq6|3W9@ZO01)__d|loIo;ebA zYX?xdBl+|LqboNU=07Cf5xYhI&QPH7OK>t*Wvj01B81rvv{C}*2!qT1w}Bo&8l9)V zHw>2U0U|tHn*(p(%jPHGqoU@4Hn?IPlKKxbYYE{OP9`hU`z(z1Rt>dTvlxt`UPFZt zgaOXRLdLXEdp3@;(~Xy;c?-qi7>Hs_1yh`g3T>dkuz4N~{r1;04`%!Lp7_$;RZ|uhJh&VZWi|mvrC? zIET#XGDE8enUmv7YR@TZ79c88SyIHfPocUx9&b~;Co7zJm>;8)UOPZjv7x!m@dHaE zYP2(!2mZaB?0_n_6Hlw#kT@ke=QF{=Y&(|@0+7nurnVXqQ6_F?E)9)p|6T6$D&v>s;JB^Nkd{YO2j8o3%P1np`rfO>ZdCkOePJ(z zQ-NxmpLcJh2)Kf~ry{8(K>G$b6>ybSa;Oo`l>`2{T@aq;Hd9j398w-v^gdNc4Fw!qln}{m&_lZ1W=vmau0Fj|nW+V{+0pR++&PG> z$&fJ)SWW6>Ukg4d(1P}V8ug}?a/__init__.py` so they are importable as + `from application.event_tag import EventTagData`. + +### 2. Repository Protocol — `application/event_tag/repository.py` + +```python +from typing import Protocol +from application.event_tag import EventTagData + +class EventTagRepository(Protocol): + def get_by_org(self, org_id: int) -> list[EventTagData]: ... + def get_by_id(self, tag_id: int) -> EventTagData | None: ... + def create(self, name: str, color: str, org_id: int) -> None: ... + def update(self, tag_id: int, name: str, color: str) -> None: ... + def delete(self, tag_id: int) -> None: ... +``` + +- Uses `typing.Protocol` (structural subtyping) — no explicit `implements` or base class needed. +- Any class that satisfies the interface is accepted by the service, including `MagicMock()` in + tests. +- Keep the Protocol in `application/` so it has **zero infrastructure dependencies**. + +### 3. Service — `application/event_tag/service.py` + +```python +class EventTagService: + def __init__(self, repository: EventTagRepository) -> None: + self._repository = repository + + def get_org_event_tags(self, org_id: int | str) -> list[EventTagData]: + return self._repository.get_by_org(int(org_id)) + + def create_org_specific_tag(self, name: str, color: str, org_id: int | str) -> None: + self._repository.create(name, color, int(org_id)) + + # ... update, delete, get_by_id +``` + +- **No infrastructure imports** — only `application.*`. +- Responsible for type coercion (e.g. `int(org_id)`) and any business rules. +- Repository is **injected** — never instantiated inside the service. + +### 4. HTTP Client — `infrastructure/api_client/client.py` + +`F3ApiClient` wraps `requests.Session` and: + +- Reads `F3_API_KEY` from env (raises `ValueError` at startup if missing). +- Reads optional `F3_API_BASE_URL` (default `https://api.f3nation.com`) and + `F3_API_TIMEOUT_SECONDS` (default `8.0`). +- Adds `Authorization: Bearer ` and `Client: f3-nation-slack-bot` headers to every request. +- Maps HTTP status codes to typed exceptions: + +| HTTP Status | Exception | +| --------------- | --------------------------- | +| 404 | `F3ApiNotFoundError` | +| 401 / 403 | `F3ApiAuthError` | +| other non-2xx | `F3ApiError` | +| network failure | `F3ApiError(status_code=0)` | + +- A **module-level singleton** (`get_f3_api_client()`) reuses the underlying connection pool. + +### 5. API Repository — `infrastructure/api_client/event_tag_repository.py` + +`ApiEventTagRepository` implements `EventTagRepository` using `F3ApiClient`: + +| Method | Endpoint | +| ----------------------------- | ---------------------------------- | +| `get_by_org(org_id)` | `GET /v1/event-tag/org/{org_id}` | +| `get_by_id(tag_id)` | `GET /v1/event-tag/id/{tag_id}` | +| `create(name, color, org_id)` | `POST /v1/event-tag` | +| `update(tag_id, name, color)` | `POST /v1/event-tag` | +| `delete(tag_id)` | `DELETE /v1/event-tag/id/{tag_id}` | + +**Key behaviours:** + +- `get_by_org` filters to tags where `specificOrgId == org_id` (mirrors legacy DB behaviour — the + API returns both org-specific and global tags). +- Raw payloads may use camelCase (`specificOrgId`) or snake_case (`specific_org_id`); the parser + handles both via `raw.get("specificOrgId", raw.get("specific_org_id"))`. +- A **module-level singleton** (`get_api_event_tag_repository()`) is provided for production use. + +### 6. Feature Module — `features/calendar/event_tag.py` + +The feature module is split into three responsibilities: + +#### Composition root helper + +```python +def _build_event_tag_service() -> EventTagService: + return EventTagService(repository=get_api_event_tag_repository()) +``` + +- Called once per handler invocation (stateless, cheap). +- Centralises wiring so tests can patch a single symbol. + +#### `EventTagViews` — Slack UI construction + +Pure functions that accept `list[EventTagData]` and return `SdkBlockView` objects. No I/O. + +| Method | Output | +| ---------------------------------------------- | --------------------------------------- | +| `build_add_tag_modal(org_tags)` | Modal for creating a new tag | +| `build_edit_tag_modal(tag, org_tags)` | Modal pre-filled with existing tag data | +| `build_tag_list_modal(org_tags, notice_text?)` | List view with edit/delete controls | + +#### Handler functions + +Standard Slack Bolt handler signature `(body, client, logger, context, region_record)`. + +| Function | Trigger type | What it does | +| ------------------------------ | ---------------- | -------------------------------------------------------------------------------------- | +| `manage_event_tags` | Action | Dispatches to add or edit list based on `selected_option.value` | +| `handle_event_tag_add` | View submission | Creates or updates a tag (edit mode detected via `private_metadata`) | +| `handle_event_tag_edit_delete` | Action (per-tag) | Edits (opens edit modal) or deletes a specific tag; refreshes list on missing-tag race | + +#### Routing registration + +All three handlers are registered in `utilities/routing.py` under the appropriate mapper +(`ACTION_MAPPER` or `VIEW_MAPPER`). Action ID constants live in the feature module itself +(not in `utilities/slack/actions.py`) because they are implementation details of this feature. + +--- + +## Access Patterns + +### Read (list by org) + +``` +Slack action → manage_event_tags() → EventTagService.get_org_event_tags(org_id) + → ApiEventTagRepository.get_by_org(org_id) + → GET /v1/event-tag/org/{org_id} + → filter specificOrgId == org_id + → list[EventTagData] +``` + +### Read (single by ID) + +``` +handle_event_tag_edit_delete() → EventTagService.get_org_event_tags() → [linear search] +# NOTE: there is no direct get_by_id call in current handlers; the tag is located +# from the already-fetched org list. get_by_id exists in the service/repo for future use. +``` + +### Create + +``` +Slack view submission → handle_event_tag_add() + → form_data parsed via EVENT_TAG_FORM.get_selected_values(body) + → EventTagService.create_org_specific_tag(name, color, org_id) + → ApiEventTagRepository.create(name, color, org_id) + → POST /v1/event-tag {name, color, specificOrgId, isActive: true} +``` + +### Update + +``` +Slack view submission (edit_event_tag_id in private_metadata) + → handle_event_tag_add() + → EventTagService.update_org_specific_tag(tag_id, name, color) + → ApiEventTagRepository.update(tag_id, name, color) + → POST /v1/event-tag {id, name, color} +``` + +### Delete + +``` +Slack action (selected_option == "Delete") + → handle_event_tag_edit_delete() + → EventTagService.delete_org_specific_tag(tag_id) + → ApiEventTagRepository.delete(tag_id) + → DELETE /v1/event-tag/id/{tag_id} +``` + +--- + +## Testing + +### Philosophy + +Each layer is tested **in isolation** using `unittest.TestCase` + `unittest.mock`. No database, +no live API, no Slack client. Run the full suite with: + +```bash +python -m pytest tests -q +``` + +### Test files + +| File | What it covers | +| -------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `tests/infrastructure/api_client/test_client.py` | `F3ApiClient` HTTP mechanics, error mapping, singleton | +| `tests/infrastructure/api_client/test_event_tag_repository.py` | `ApiEventTagRepository` endpoint calls, payload parsing, filtering | +| `tests/features/calendar/test_event_tag.py` | `EventTagService`, `EventTagViews`, handler functions, composition root | + +### Patterns used + +#### Service tests — inject a `MagicMock` repository + +```python +def test_get_org_event_tags(self): + repo = MagicMock() + repo.get_by_org.return_value = [_make_tag()] + service = EventTagService(repository=repo) + result = service.get_org_event_tags("1") + repo.get_by_org.assert_called_once_with(1) # verifies int coercion +``` + +#### Repository tests — inject a `MagicMock` client + +```python +def setUp(self): + self.client = MagicMock() + self.repo = ApiEventTagRepository(self.client) + +def test_get_by_org_filters_to_requested_org(self): + self.client.get.return_value = {"eventTags": [...]} + result = self.repo.get_by_org(10) + self.client.get.assert_called_once_with("/v1/event-tag/org/10") +``` + +#### HTTP client tests — patch `requests.Session` + +```python +with patch("infrastructure.api_client.client.requests.Session") as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.get.return_value = _make_response(json_payload={"ok": True}) + with patch.dict(os.environ, {"F3_API_KEY": "test-key"}, clear=True): + client = F3ApiClient() + result = client.get("/v1/event-tag") +``` + +#### Handler tests — patch `_build_event_tag_service` + +```python +@patch("features.calendar.event_tag._build_event_tag_service") +def test_manage_event_tags_add(self, mock_build_service): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + ... +``` + +Patching the factory keeps tests from touching any I/O while still exercising the full handler +flow. + +#### Composition root test + +```python +@patch("features.calendar.event_tag.get_api_event_tag_repository") +@patch("features.calendar.event_tag.EventTagService") +def test_build_event_tag_service_uses_api_repository(self, mock_svc_cls, mock_get_repo): + result = _build_event_tag_service() + mock_get_repo.assert_called_once_with() + mock_svc_cls.assert_called_once_with(repository=mock_get_repo.return_value) +``` + +--- + +## Step-by-Step: Migrating a New Domain + +Follow these steps for each new domain (e.g. `event_type`, `location`, `ao`). + +### Step 1 — Define the data model + +Create `application//__init__.py` with a Pydantic `BaseModel`. + +### Step 2 — Define the repository Protocol + +Create `application//repository.py` with a `typing.Protocol` class listing all required +data-access methods. Use primitive Python types in method signatures (no ORM types). + +### Step 3 — Write the service + +Create `application//service.py`. The `__init__` accepts a `Repository`. +Business logic goes here; no infrastructure imports allowed. + +### Step 4 — Implement the API repository + +Create `infrastructure/api_client/_repository.py`. Implement the Protocol against +`F3ApiClient`. Add a `get_api__repository()` singleton factory at module level. +Export from `infrastructure/api_client/__init__.py`. + +### Step 5 — Update the feature module + +In `features/.../.py`: + +1. Add `_build__service()` factory using the API repository. +2. Refactor handler functions to call the service instead of `DbManager`. +3. Extract view-building into a `Views` class (pure functions, no I/O). + +**Dynamic selector options**: For form blocks whose options are loaded at render time (e.g. a +location dropdown populated from the DB/API), set options **directly** on the element after +calling the modal builder, rather than using `set_options()`: + +```python +form = AoViews.build_add_ao_modal(locations) +location_block = form.get_block(actions.CALENDAR_ADD_AO_LOCATION) +location_block.element.options = [Option(text=..., value=str(loc.id)) for loc in locations] +``` + +This avoids the `option.label is None` bug in `set_options()` and keeps the modal builder testable +without requiring a live list of options. + +### Step 6 — Register in routing + +Ensure all action/view IDs are registered in `utilities/routing.py`. Constants for feature-local +IDs live in the feature file; shared IDs live in `utilities/slack/actions.py`. + +**Check all three places in `routing.py`** where an action ID constant may appear: + +1. `ACTION_MAPPER` — the main action dispatch table. +2. `VIEW_MAPPER` — for view submission callback IDs. +3. `ACTION_PREFIXES` — a list of prefix strings used for pattern-matched action IDs (e.g. + `event-type-edit-delete_`). Any per-row action ID that uses a `_` suffix must + also be updated here when its constant moves from `utilities/slack/actions.py` to the + feature module. + +### Step 7 — Write tests + +Create test files mirroring the structure above: + +- `tests/infrastructure/api_client/test__repository.py` +- `tests/features//test_.py` + +Optionally add `tests/application//test_service.py` if the service contains significant +business logic. + +### Step 8 — Verify + +```bash +python -m pytest tests -q +``` + +--- + +## Environment Variables + +| Variable | Required | Default | Purpose | +| ------------------------ | -------- | -------------------------- | ----------------------------------- | +| `F3_API_KEY` | Yes | — | Bearer token for F3 Nation API | +| `F3_API_BASE_URL` | No | `https://api.f3nation.com` | Override API base URL (dev/staging) | +| `F3_API_TIMEOUT_SECONDS` | No | `8.0` | Per-request timeout | + +--- + +## Domain-Specific API Notes + +Always consult `docs/API_REFERENCE.md` before implementing any repository. **Do not guess +endpoint paths by analogy with other domains** — the API is not uniform. Key differences +discovered during migration: + +### location + +| Aspect | Detail | +| ------------------------- | -------------------------------------------------------------------------------------------------------- | +| List by org | `GET /v1/location?regionIds={id}` — no `/org/{id}` path exists | +| Delete | `DELETE /v1/location/delete/{id}` — not `/id/{id}` | +| Response field | `locationName` in GET responses | +| Request field | `name` in POST bodies (not `locationName`) | +| Required fields on update | Crupdate POST always requires `name`, `orgId`, and `isActive` — even for updates | +| Active-only filtering | The list endpoint returns both active and inactive records; filter `is_active=True` in the service layer | + +### event-tag + +| Aspect | Detail | +| ----------- | --------------------------------------------------------------------------------------- | +| List by org | `GET /v1/event-tag/org/{orgId}` | +| Delete | `DELETE /v1/event-tag/id/{id}` | +| Filtering | API returns global + org-specific tags; filter to `specificOrgId == org_id` client-side | + +### org (AO) + +| Aspect | Detail | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| List by parent org | `GET /v1/org?orgTypes=ao&parentOrgIds={id}&statuses=active` — use query params, no sub-path | +| Get single | `GET /v1/org/id/{ao_id}` | +| Create / Update | `POST /v1/org` (crupdate — omit `id` to create, include `id` to update) | +| Delete | `DELETE /v1/org/delete/{id}` — cascades automatically to child Events and EventInstances; do **not** delete them manually | +| Response envelope | `"orgs"` (list), `"org"` (single); also accept `"results"` / `"result"` as fallbacks | +| Response fields | camelCase: `parentId`, `orgType`, `isActive`, `defaultLocationId`, `logoUrl`, `meta` | +| Required crupdate fields | `name`, `orgType`, `parentId`, `isActive`, `website`, `twitter`, `facebook`, `instagram` — must be sent on every POST even when only updating one field; pass empty string `""` for unused social fields | +| `meta` field | Contains `slack_channel_id` (snake_case key inside the `meta` dict) | +| Logo update | No dedicated endpoint — upload file to storage, then call crupdate POST again with all required fields plus `logoUrl`; keep all form values in scope before the upload | + +### event-instance + +| Aspect | Detail | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| List by region | `GET /v1/event-instance?regionOrgId={id}&startDate={YYYY-MM-DD}` | +| AO filter | Add `aoOrgId={id}` query param to scope list to a specific AO | +| Create / Update | `POST /v1/event-instance` (crupdate — omit `id` to create, include `id` to update) | +| Get single | `GET /v1/event-instance/id/{id}` | +| Delete | `DELETE /v1/event-instance/id/{id}` — **hard delete** (unlike most other domains which soft-delete) | +| Close | `POST /v1/event-instance` with the existing event-instance payload plus `seriesException: "closed"` and updated `meta` | +| Reopen | `POST /v1/event-instance` with the existing event-instance payload plus `seriesException: null` | +| Response envelope | `eventInstances` (list); `eventInstance` (single); fall back to `results` / `result` | +| Response fields | camelCase: `orgId`, `locationId`, `startDate`, `startTime`, `endTime`, `isActive`, `isPrivate`, `seriesException`, `eventTypes`, `eventTags`, `preblastRich` | +| `startDate` | Returned as `"YYYY-MM-DD"` string — parse to `datetime.date` in `_parse_instance` | +| `eventTypes` / `eventTags` | Returned as nested objects `[{"id": N, ...}]` or bare int lists; handle both in `_parse_instance` | +| `seriesException` values | `"closed"` \| `"different-time"` \| `"miscellaneous"` \| `null` — compare as plain strings (no enum) | +| Attendance creation | Still uses `DbManager.create_record(Attendance(...))` — attendance migration is a separate step; do not migrate it alongside event-instance | +| Existing services for form options | Reuse `AoService`, `LocationService`, `EventTypeService`, `EventTagService` instead of fetching via `DbManager` for dropdown population | + +### series (event) + +The "series" domain maps to the F3 Nation API's `event` resource (a recurring event template). + +| Aspect | Detail | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| List by region | `GET /v1/event?regionIds=[id]&statuses=["active"]` | +| AO filter | Replace `regionIds` with `aoIds=[ao_id]` to scope the list to one AO | +| Create / Update | `POST /v1/event` (crupdate — omit `id` to create, include `id` to update) | +| Get single | `GET /v1/event/id/{id}` | +| Delete | `DELETE /v1/event/delete/{id}` — soft-deletes series and all future instances | +| Response envelope | `"events"` (list), `"event"` (single / crupdate); also accept `"results"` / `"result"` fallbacks | +| Response `org_id` differences | Crupdate response uses top-level `orgId`; `GET /v1/event/id/{id}` nests AO as `aos[0].aoId`; list response nests AO as `parents[0].parentId`. The `_parse_series()` helper handles all three cases. | +| `day_of_week` format | Returned and sent as lowercase string: `"monday"`, `"tuesday"`, etc. (not an enum). Replace all `.name.capitalize()` accesses with `.capitalize()` on the string. | +| `start_date` / `end_date` | Returned and sent as `"YYYY-MM-DD"` strings (no datetime conversion needed). | +| `start_time` / `end_time` | Returned and sent as `"HHMM"` strings (e.g. `"0530"`). Slack timepickers return `"HH:MM"` — convert with `.replace(":", "")` before sending. | +| Event tags NOT returned | **Neither the list endpoint nor the single-by-ID endpoint returns event tags for events.** `SeriesData.event_tag_ids` will always be `[]` when fetched from the API. The edit form cannot pre-fill the event tag selection; this is accepted UX behaviour. | +| Event types in responses | Returned as nested objects `[{"eventTypeId": N, "eventTypeName": "..."}]` — parse via `t.get("eventTypeId")`. | +| `start_date` required for update | The crupdate POST requires `startDate` even for updates, but the edit form hides the start-date field. Solution: fetch the existing series first via `service.get_by_id()` and pass its `start_date` to the update call. | +| `day_of_week` immutable on edit | The edit form does not expose recurrence fields (DOW, frequency, interval, index, start/end date). Do **not** send `dayOfWeek` (or recurrence fields) in the update payload — they are immutable and including them may cause API errors. | +| Cascade behaviour — create | `POST /v1/event` (create) automatically generates all future `EventInstance` records. The old `create_events()` function is **entirely removed** — do not replicate it. | +| Cascade behaviour — update | `POST /v1/event` (update) automatically updates all future `EventInstance` fields. The old `update_events()` function is **entirely removed** — do not replicate it. | +| Cascade behaviour — delete | `DELETE /v1/event/delete/{id}` automatically soft-deletes all future `EventInstance` records. The old `DbManager.update_records(EventInstance, ...)` call is **entirely removed**. | +| Multiple DOW on create | Creating with multiple days of week (e.g. Mon+Wed) requires calling the API once per day. Loop over `day_of_weeks` and call `service.create_series()` for each; collect returned `SeriesData` objects for map revalidation. | + +**Removed code (handled by API cascade):** + +The following three functions were deleted from `features/calendar/series.py` during migration: + +- `create_events(records)` — generated EventInstances from a list of Event records +- `update_events(series)` — updated future EventInstances to match series fields +- `_is_last_occurrence_of_dow_in_month(date, dow_name)` — helper used only by `create_events` + +All cascade logic is now entirely handled server-side by the F3 Nation API. + +### position + +| Aspect | Detail | +| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| List org-specific positions | `GET /v1/position/org/{orgId}` — excludes global/national positions | +| List with assignments | `GET /v1/position/assignments/{orgId}?regionOrgId={regionOrgId}` — returns positions with nested `users` array | +| `regionOrgId` param | Required context for the assignments endpoint; pass the parent region ID to get the correct tier (region vs AO) of positions | +| Create / Update | `POST /v1/position` (crupdate — omit `id` to create, include `id` to update); only `name` is required | +| Delete | `DELETE /v1/position/id/{id}` — soft delete | +| Bulk assignment replace | `PUT /v1/position/assignments` — deprecated but kept for backward compatibility; atomically replaces all assignments for an org: `{orgId, assignments: [{positionId, userIds: []}]}` | +| Response envelope | `positions` (list), `position` (single) | +| `orgType` values | Plain strings: `"region"`, `"ao"`, `"area"`, `"sector"`, `"nation"` | +| Slack ID ↔ F3 user ID mapping | The assignments API returns F3 user IDs, not Slack user IDs. Build a `{f3_user_id: slack_id}` dict from `SLACK_USERS.values()` (filtering by `slack_team_id`) to populate Slack multi-user selects. For saving, use `get_user(slack_id, ...)` → `SlackUser.user_id` to get the F3 integer ID. | +| `F3ApiClient.put()` method | The base client did not have a `put()` method. It was added to support the bulk-assignment endpoint. Future domains needing HTTP PUT should use this method. | + +### General patterns + +- **Crupdate POST**: Most domains use a single `POST` endpoint for both create and update. Omit + `id` to create; include `id` to update. Always send all required fields — the API validates + them even on updates. +- **Request vs. response field names**: Some domains use different field names in requests vs. + responses (e.g. location's `name` → `locationName`). Verify both directions in the API + reference before writing `_parse_*` and payload-building code. +- **Active/inactive records**: Not all list endpoints filter to active records automatically. + Check the API reference for `statuses` filter params; if absent, filter in the service layer. + +--- + +## Common Pitfalls + +| Pitfall | Fix | +| ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Importing `F3ApiClient` inside `application/` | Never — only `infrastructure/` may import it | +| Instantiating `EventTagService` with a hard-coded repository | Always inject via `_build_*_service()` | +| Forgetting to filter global tags in `get_by_org` | The API returns global + org-specific; filter to `specificOrgId == org_id` | +| camelCase vs snake_case API fields | Use `raw.get("camelKey", raw.get("snake_key"))` in `_parse_*` helpers | +| Singleton not reset between tests | Patch the module-level `_repo`/`_client` variable with `None` in singleton tests | +| Moving a constant from `actions.py` only updated `ACTION_MAPPER` | Also update `ACTION_PREFIXES` in `routing.py` — any per-row suffix action (e.g. `edit-delete_`) appears there too | +| `set_options()` fails with `TypeError: 'NoneType' object is not subscriptable` | `SdkBlockView.set_options()` truncates `option.label` but the SDK `Option` object has `label=None` by default. **Fix 1 (applied)**: guard in `sdk_orm.py` with `if option.label is not None`. **Fix 2 (preferred for dynamic lists)**: bypass `set_options()` entirely and set options directly: `form.get_block(block_id).element.options = options_list` after `build_add_*_modal()` returns. | +| Orphaned option-setting code from dead UI blocks | During migration, audit every `set_options()` call and confirm its block still exists in the form. Legacy modules often accumulated option-setting code for blocks that were later removed (e.g. `CALENDAR_ADD_AO_TYPE` options in old `ao.py`). Remove them. | +| Logo / file uploads require a second API call | There is no PATCH endpoint for partial updates — logo update is a full crupdate POST. Ensure all required fields are still in scope after the file upload completes before making the second call. | +| `replace_string_in_file` leaves old code below the replaced block | The tool replaces only the matched text; content below it remains. When rewriting an entire file, write the complete new content to a temp file and `mv` it into place (or use `head -N` to truncate). | +| Cascade delete misunderstood | `DELETE /v1/org/delete/{id}` cascades to Events and EventInstances automatically — no need to iterate and delete children manually as the old DbManager code did. Always check the API reference for cascade behaviour before writing delete logic. | +| Handler mocks use `mock.return_value.*` when handler calls static methods | If the feature module calls `Views.build_modal()` as a static/class method (not `Views().build_modal()`), patch assertions use `mock_views.build_modal` not `mock_views.return_value.build_modal`. Check how the feature code actually calls the Views class. | +| Empty list modal crashes with `SlackObjectFormationError: views must contain between 1 and 100 blocks` | Slack rejects a modal view with zero blocks. Any `build_list_modal()` that iterates over a potentially-empty list must add a notice `SectionBlock` when the list is empty: `if not items: return SdkBlockView(blocks=[SectionBlock(text="No items found.", block_id="-notice")])` | +| Guessing endpoint paths by analogy | Always read `docs/API_REFERENCE.md` first — `/org/{id}`, `/delete/{id}`, and list query params vary per domain | +| Crupdate update missing required fields | When updating via crupdate POST, include all required fields (`orgId`, `isActive`, social fields like `website`/`twitter`/`facebook`/`instagram` for org, etc.) not just the ones being changed — the API validates all required fields on every POST. Use `""` (empty string) for unused string fields rather than `null`/omitting them. | +| Request and response use different field names | Verify both request payload keys and response field names in the API reference separately (e.g. location sends `name` but receives `locationName`) | +| List endpoint returns inactive records | After fetching a list, check `is_active` and filter in the service if the endpoint does not support a `statuses=active` param | +| Hard delete vs soft delete | Most legacy features used `DbManager.update_record(..., {"is_active": False})` for soft deletion. Check the API reference — some endpoints (e.g. `event-instance`) are hard deletes via `DELETE`. Using the wrong verb leads to permanently lost data or orphaned records. | +| Close / Reopen requires preserved fields | The event-instance close/reopen POST is validated like any other crupdate. Fetch the current instance with `get_by_id()`, preserve its required fields (`name`, `orgId`, `startDate`, `startTime`, `endTime`, `eventTypeId`, flags, etc.), then send the state change (`seriesException`) alongside the merged `meta`. | +| Junction table cleanup handled by API | Old code manually deleted junction records (e.g. `EventTag_x_EventInstance`) before updating a relation. The API handles this automatically when you send the full new list (`eventTagIds: []` clears all tags). Do not replicate the manual deletion in the migrated code. | +| ORM relation attributes replaced by ID lists | Legacy SQLAlchemy models expose relation objects (e.g. `event_tags`, `event_types`) as joined lists; API data models return ID lists (`event_tag_ids`, `event_type_ids`). Update all attribute accesses — e.g. `instance.event_types[0].id` → `instance.event_type_ids[0]`, `instance.event_tags` → `instance.event_tag_ids`. | +| `orm.BlockView.get_selected_values()` needs `view.blocks` in test body | The legacy `BlockView.get_selected_values(body)` reads `body["view"]["blocks"]` to map block IDs to action IDs. Slack always sends this in real events, but unit tests that mock the body must include a minimal `blocks` list to avoid `KeyError: 'blocks'`. | +| Features that consume multiple domain services | When a feature module calls five domain services at render time (AOs, locations, event types, event tags, instances), add a `_build__service()` composition root helper for each. Patch each helper separately in handler tests to keep test setup tractable. | +| `series_exception` string vs enum | The legacy codebase compared against a Python enum (`Series_Exception.closed`). The API returns a plain string (`"closed"`). Replace all enum comparisons with string literals after migration. | +| Crupdate requires fields the edit form omits | Some edit forms intentionally hide certain fields (e.g. the series edit form hides `start_date`, `end_date`, `day_of_week`, and recurrence fields). The crupdate POST still requires those fields. Fetch the existing record first, then forward the preserved values in the update call. Never send stale defaults (e.g. `None`) for required API fields just because they are not in the form. | +| API does not return event tags for series | `GET /v1/event` and `GET /v1/event/id/{id}` do not include event tag data — the edit form cannot pre-fill the tag selection. Accept this UX limitation; do not work around it by calling an additional API to infer tag associations. | +| Slack user ID ↔ F3 user ID mismatch in assignment forms | Slack multi-user selects use Slack user IDs; the position assignments API returns F3 integer user IDs. Build a reverse map `{su.user_id: su.slack_id for su in SLACK_USERS.values() if su.slack_team_id == team_id}` when populating initial values. When saving, resolve Slack IDs back to F3 IDs via `get_user(slack_id, ...)` → `SlackUser.user_id`. | +| `F3ApiClient` missing HTTP verbs | The base client was initially limited to `get`, `post`, and `delete`. When a domain needs `PUT` (e.g. the deprecated bulk position-assignment endpoint), add the verb to `F3ApiClient._request`-based helper methods before implementing the repository. | +| Deprecated bulk-replace endpoint is still correct approach | Some older API endpoints are marked "deprecated" but remain the only atomic way to perform a multi-record operation (e.g. `PUT /v1/position/assignments` for replacing all position assignments). Prefer them over a sequence of individual creates/deletes unless the API provides a supported bulk alternative. | diff --git a/apps/slackbot/docs/API_REFERENCE.md b/apps/slackbot/docs/API_REFERENCE.md new file mode 100644 index 00000000..dc4ad597 --- /dev/null +++ b/apps/slackbot/docs/API_REFERENCE.md @@ -0,0 +1,270 @@ +# F3 Nation API Reference + +**Live deployed spec**: `https://api.f3nation.com/docs/openapi.json` +**Local spec**: `/docs/openapi.json` +**Interactive docs**: `https://api.f3nation.com/docs` +**API version**: 4.2.2 + +> This file is a curated summary for AI agents and developers. It covers auth, +> conventions, and the domains relevant to this Slack bot. For full schema details, +> fetch the live OpenAPI JSON above. + +--- + +## Authentication + +Every request requires **both** of the following headers: + +``` +Authorization: Bearer +Client: f3-nation-slack-bot +``` + +- `F3_API_KEY` — env var; never commit to source control. +- `Client` — identifies this application; must be a non-empty string. +- **Rate limit**: 200 requests per 60 seconds → `429 Too Many Requests`. + +See `infrastructure/api_client/client.py` for the implementation. + +--- + +## Response Conventions + +### Envelope keys + +Most endpoints wrap results in a typed envelope key: + +| Domain | List key | Single key | +| -------------- | ------------ | ----------- | +| event-tag | `eventTags` | `eventTag` | +| event-type | `eventTypes` | `eventType` | +| org | `orgs` | `org` | +| location | `locations` | `location` | +| event | `events` | `event` | +| event-instance | _(varies)_ | _(varies)_ | +| user | `users` | `user` | +| position | `positions` | `position` | + +### Field naming + +API responses use **camelCase** (`specificOrgId`, `isActive`). Some older or alternative +payloads may use snake_case. Repository implementations must handle both: + +```python +raw.get("specificOrgId", raw.get("specific_org_id")) +``` + +### Create vs Update (crupdate pattern) + +Most domains use a single `POST` endpoint for both create and update: + +- **Omit `id`** → creates a new record. +- **Include `id`** → updates the existing record. + +### Soft delete + +`DELETE` operations mark records as `isActive: false` rather than removing them. +The response returns the deleted record's ID (e.g., `{ "eventTagId": 42 }`). + +### Pagination + +List endpoints accept `pageIndex` (0-based) and `pageSize`. Responses include `totalCount`. + +--- + +## Domains + +### event-tag + +| Method | Path | Purpose | +| -------- | --------------------------- | --------------------------------------------------------------- | +| `GET` | `/v1/event-tag` | List all tags (paginated, filterable by `orgIds`, `statuses`) | +| `POST` | `/v1/event-tag` | Create or update a tag (crupdate) | +| `GET` | `/v1/event-tag/org/{orgId}` | All tags for an org (global + org-specific; filter client-side) | +| `GET` | `/v1/event-tag/id/{id}` | Single tag by ID | +| `DELETE` | `/v1/event-tag/id/{id}` | Soft delete | + +**Create/update payload:** + +```json +{ "name": "CSAUP", "color": "#32CD32", "specificOrgId": 123, "isActive": true } +``` + +**Update additionally requires:** `"id": 42` + +**Response object fields:** +`id`, `name`, `description`, `color`, `specificOrgId`, `isActive`, `created`, `updated` + +> **Important**: `GET /v1/event-tag/org/{orgId}` returns **both** global (nation-wide) and +> org-specific tags. Filter to `specificOrgId == orgId` to get only org-specific ones. +> See `infrastructure/api_client/event_tag_repository.py`. + +--- + +### org + +Organizations are hierarchical: `nation → sector → area → region → ao` + +| Method | Path | Purpose | +| -------- | --------------------- | ------------------------------------------------------------ | +| `GET` | `/v1/org` | List orgs (filter by `orgTypes`, `parentOrgIds`, `statuses`) | +| `POST` | `/v1/org` | Create or update an org | +| `GET` | `/v1/org/id/{id}` | Single org by ID | +| `GET` | `/v1/org/accessible` | Orgs the caller has editor/admin role on | +| `GET` | `/v1/org/mine` | Orgs where the caller has any role | +| `DELETE` | `/v1/org/delete/{id}` | Soft delete (cascades to AO events/instances) | +| `GET` | `/v1/org/count` | Count matching orgs | + +**Response object fields:** +`id`, `parentId`, `name`, `orgType`, `defaultLocationId`, `description`, `isActive`, +`logoUrl`, `website`, `email`, `twitter`, `facebook`, `instagram`, `lastAnnualReview`, +`meta`, `created`, `updated`, `aoCount`, `parentOrgName`, `parentOrgType` + +**`orgType` values:** `"ao"` | `"region"` | `"area"` | `"sector"` | `"nation"` + +--- + +### location + +| Method | Path | Purpose | +| -------- | ------------------------------ | -------------------------------------------------- | +| `GET` | `/v1/location` | List locations (filter by `regionIds`, `statuses`) | +| `POST` | `/v1/location` | Create or update a location | +| `GET` | `/v1/location/id/{id}` | Single location by ID | +| `DELETE` | `/v1/location/delete/{id}` | Soft delete (admin role required) | +| `GET` | `/v1/location/in-bounding-box` | Locations within lat/lng bounds | + +**Response object fields:** +`id`, `locationName`, `orgId`, `regionId`, `regionName`, `description`, `isActive`, +`latitude`, `longitude`, `email`, `addressStreet`, `addressStreet2`, `addressCity`, +`addressState`, `addressZip`, `addressCountry`, `meta`, `created`, `updated` + +--- + +### event + +Recurring series events. Future instances are auto-generated on create/update. + +| Method | Path | Purpose | +| -------- | ----------------------- | -------------------------------------------------------------------- | +| `GET` | `/v1/event` | List events (filter by `regionIds`, `aoIds`, `eventTypeNames`, etc.) | +| `POST` | `/v1/event` | Create or update an event (crupdate) | +| `GET` | `/v1/event/id/{id}` | Single event by ID | +| `DELETE` | `/v1/event/delete/{id}` | Soft delete (future instances also deleted) | +| `GET` | `/v1/event/count` | Count matching events | + +**Create/update required fields:** `isActive`, `highlight`, `startDate`, `name`, `regionId`, `aoId`, `eventTypeIds` + +**`recurrencePattern` values:** `"weekly"` | `"monthly"` +**`dayOfWeek` values:** `"monday"` | `"tuesday"` | … | `"sunday"` +**`startTime` / `endTime` format:** 4-digit string, e.g. `"0600"` + +--- + +### event-instance + +Individual occurrences of events (can be standalone or part of a series). + +| Method | Path | Purpose | +| -------- | ------------------------------------------- | ---------------------------------------------------------------------- | +| `GET` | `/v1/event-instance` | List instances (filter by `regionOrgId`, `aoOrgId`, `startDate`, etc.) | +| `POST` | `/v1/event-instance` | Create or update an instance | +| `GET` | `/v1/event-instance/id/{id}` | Single instance by ID | +| `DELETE` | `/v1/event-instance/id/{id}` | Hard delete | +| `GET` | `/v1/event-instance/calendar-home-schedule` | Calendar home view (user attendance + Q info) | +| `GET` | `/v1/event-instance/upcoming-qs` | Instances where user is Q/Co-Q (for preblast) | +| `GET` | `/v1/event-instance/past-qs` | Past instances where user is Q/Co-Q (for backblast) | +| `GET` | `/v1/event-instance/without-q` | Past instances with no Q assigned | + +**Instance-specific fields:** `preblast`, `preblastRich`, `preblastTs`, `backblast`, +`backblastRich`, `backblastTs`, `paxCount`, `fngCount`, `seriesException` + +**`seriesException` values:** `"closed"` | `"different-time"` | `"miscellaneous"` + +--- + +### event-type + +| Method | Path | Purpose | +| -------- | ---------------------------- | --------------------------------------------------------------------- | +| `GET` | `/v1/event-type` | List event types (filter by `orgIds`, `ignoreNationEventTypes`, etc.) | +| `POST` | `/v1/event-type` | Create or update an event type | +| `GET` | `/v1/event-type/org/{orgId}` | Event types for a specific org | +| `GET` | `/v1/event-type/id/{id}` | Single event type by ID | +| `DELETE` | `/v1/event-type/id/{id}` | Soft delete (removes event associations) | + +**`eventCategory` values:** `"first_f"` | `"second_f"` | `"third_f"` + +--- + +### attendance + +| Method | Path | Purpose | +| -------- | --------------------------------------------------------------- | ------------------------------------------------ | +| `GET` | `/v1/attendance/event-instance/{eventInstanceId}` | All attendance for an instance | +| `POST` | `/v1/attendance` | Create planned attendance (HC) | +| `POST` | `/v1/attendance/actual` | Create actual attendance (backblast) | +| `DELETE` | `/v1/attendance/event-instance/{eventInstanceId}/actual` | Delete all actual attendance (for re-submission) | +| `DELETE` | `/v1/attendance/event-instance/{eventInstanceId}/user/{userId}` | Remove planned attendance | +| `PATCH` | `/v1/attendance/{attendanceId}/types` | Update attendance types | +| `POST` | `/v1/attendance/take-q` | Sign up as Q | +| `DELETE` | `/v1/attendance/remove-q` | Remove Q status (keeps HC) | +| `PUT` | `/v1/attendance/assign-q` | Assign Q + Co-Qs (demotes existing Q to HC) | + +--- + +### user + +| Method | Path | Purpose | +| -------- | ------------------------ | ---------------------------------------------------- | +| `GET` | `/v1/user` | List users (filter by `orgIds`, `roles`, `statuses`) | +| `POST` | `/v1/user` | Create or update a user | +| `GET` | `/v1/user/id/{id}` | Single user by ID | +| `GET` | `/v1/user/email/{email}` | User by email address | +| `GET` | `/v1/user/orgs` | Users by org (includes descendants) | +| `DELETE` | `/v1/user/delete/{id}` | Permanent delete (nation admin only) | + +**Role values:** `"user"` | `"editor"` | `"admin"` + +**PII fields** (`email`, `phone`, `emergencyContact`, etc.) require `includePii=true` +query param and admin role. + +--- + +### position + +| Method | Path | Purpose | +| -------- | -------------------------------------------------------------------------- | --------------------------------------------- | +| `GET` | `/v1/position` | List positions (filter by `orgId`, `orgType`) | +| `POST` | `/v1/position` | Create or update a position | +| `GET` | `/v1/position/org/{orgId}` | Org-specific positions | +| `GET` | `/v1/position/id/{id}` | Single position by ID | +| `DELETE` | `/v1/position/id/{id}` | Soft delete | +| `GET` | `/v1/position/assignments/{orgId}` | Positions + assigned users for an org | +| `POST` | `/v1/position/assignments` | Add a single user→position assignment | +| `DELETE` | `/v1/position/assignments/org/{orgId}/position/{positionId}/user/{userId}` | Remove assignment | +| `GET` | `/v1/position/assignments/user/{userId}` | All assignments for a user | + +--- + +### ping + +``` +GET /v1/ping +``` + +No auth required. Returns `{ "alive": true, "timestamp": "..." }`. + +--- + +## Error Handling + +| HTTP Status | Meaning | Python exception | +| --------------- | ---------------------------------------- | --------------------------- | +| 401 / 403 | Invalid/expired key or insufficient role | `F3ApiAuthError` | +| 404 | Resource not found | `F3ApiNotFoundError` | +| 429 | Rate limit exceeded | `F3ApiError` | +| 5xx | Server error | `F3ApiError` | +| network failure | DNS / connection error | `F3ApiError(status_code=0)` | + +See `infrastructure/api_client/exceptions.py`. diff --git a/apps/slackbot/docs/ARCHITECTURE.md b/apps/slackbot/docs/ARCHITECTURE.md new file mode 100644 index 00000000..892f0748 --- /dev/null +++ b/apps/slackbot/docs/ARCHITECTURE.md @@ -0,0 +1,248 @@ +# F3 Nation Slack Bot — Architecture Reference + +This document is intended for AI coding agents and new contributors. It describes the +system's layered architecture, data flow, key abstractions, and conventions. + +--- + +## High-Level Overview + +``` +Slack (event / action / view submission) + │ HTTPS POST + ▼ +main.py → get_request_type() → MAIN_MAPPER + │ + ▼ +utilities/routing.py + COMMAND_MAPPER | VIEW_MAPPER | ACTION_MAPPER + │ + ▼ +features/.py (handler function) + │ + ├── utilities/slack/sdk_orm.py (Slack UI blocks) + ├── application//service.py (business logic) + │ └── application//repository.py (Protocol) + │ △ + │ implemented by + └── infrastructure/api_client/_repository.py + └── infrastructure/api_client/client.py → F3 Nation REST API +``` + +--- + +## Layer Responsibilities + +### `features/` + +- **What**: Slack interaction handlers. One file (or sub-package) per feature area. +- **Allowed imports**: `application.*`, `utilities.*`, `slack_sdk.*`, stdlib. +- **Forbidden imports**: `infrastructure.*` (except through `_build_*_service()` factories), + `f3_data_models.*` (being phased out). +- **Pattern**: `EventTagViews` class for pure UI construction + handler functions for + orchestration. See `features/calendar/event_tag.py` as the reference. + +### `application/` + +- **What**: Business logic and data model definitions. +- **Allowed imports**: `application.*`, stdlib, `pydantic`. +- **Forbidden imports**: `infrastructure.*`, `features.*`, `slack_sdk.*`, `requests.*`. +- **Pattern**: `Service` class injected with a `Repository` Protocol. + +### `infrastructure/` + +- **What**: I/O implementations — currently the F3 Nation REST API client. +- **Allowed imports**: `application.*` (for data models), `requests`, stdlib. +- **Sub-packages**: + - `api_client/` — HTTP transport (`F3ApiClient`) + per-domain repository implementations. + - `persistence/sqlalchemy/` — Legacy SQLAlchemy helpers (being deprecated). + +### `utilities/` + +- **What**: Shared helpers that do not belong to a single feature. +- Key files: + - `routing.py` — Maps Slack event IDs → handler functions. + - `helper_functions.py` — `safe_get()`, `get_region_record()`, region cache. + - `slack/sdk_orm.py` — `SdkBlockView` wrapper, `as_selector_options()`. + - `slack/actions.py` — Centralized Slack action/callback ID string constants. + - `builders.py` — Shared modal building helpers (`add_loading_form`, etc.). + +### `scripts/` + +- **What**: Scheduled Cloud Run Jobs (hourly runner and sub-tasks). +- Deployed as a **separate Docker image** with heavy dependencies (Playwright, pandas). +- Calls back to the main app via the `/hourly-runner-complete` HTTP endpoint. + +--- + +## Routing System + +Every Slack interaction is dispatched by `utilities/routing.py`: + +```python +ACTION_MAPPER = { + "action-id-string": (handler_function, show_loading_modal: bool), +} +VIEW_MAPPER = { "callback-id": (handler_function, bool) } +COMMAND_MAPPER = { "/slash-command": (handler_function, bool) } +``` + +- The `bool` flag triggers a loading modal before the handler runs (set `True` for slow handlers). +- **Always add new interactions here** before wiring them in Slack. +- Feature-local action IDs are defined as module-level constants in the feature file. +- Shared/reused action IDs live in `utilities/slack/actions.py`. + +--- + +## Slack UI Abstractions + +### `SdkBlockView` (`utilities/slack/sdk_orm.py`) + +Thin wrapper around a list of `slack_sdk.models.blocks` objects. Key methods: + +| Method | Purpose | +| ------------------------------------- | ---------------------------------------------- | +| `set_initial_values(mapping)` | Pre-fill input blocks by `block_id` | +| `get_selected_values(body)` | Extract submitted form values | +| `get_block(block_id)` | Find a block by ID (returns `None` if missing) | +| `post_modal(client, trigger_id, ...)` | Open a new modal | +| `update_modal(client, view_id, ...)` | Update an open modal | + +### `as_selector_options(names, values?)` (`utilities/slack/sdk_orm.py`) + +Converts lists of strings to `slack_sdk.models.blocks.basic_components.Option` objects for use +in `StaticSelectElement`. + +### Legacy ORM (`utilities/slack/orm.py`) + +Custom `InputBlock` / `BaseElement` classes with `.as_form_field()`. **Do not use for new +features** — use `SdkBlockView` instead. + +--- + +## Database Access (Legacy Path) + +While the API migration is in progress, some features still use `DbManager` from +`f3_data_models.utils`: + +```python +from f3_data_models.utils import DbManager +records = DbManager.find_records(EventTag, filters=[EventTag.org_id == org_id]) +``` + +The `SlackSettings` ORM (`utilities/database/orm/__init__.py`) stores per-workspace configuration +and is accessed via `get_region_record(team_id)`. + +**Do not add new `DbManager` calls.** New features must use the API layer. + +--- + +## Environment Variables + +| Variable | Required | Default | Purpose | +| ------------------------ | ------------------ | -------------------------- | -------------------------------------------- | +| `F3_API_KEY` | Yes (API features) | — | Bearer token for F3 Nation REST API | +| `F3_API_BASE_URL` | No | `https://api.f3nation.com` | Override API base URL | +| `F3_API_TIMEOUT_SECONDS` | No | `8.0` | Per-request HTTP timeout | +| `SLACK_SIGNING_SECRET` | Yes | — | Verifies Slack request signatures | +| `SLACK_BOT_TOKEN` | Yes (dev) | — | Bot OAuth token | +| `LOCAL_DEVELOPMENT` | No | `false` | Disables Cloud Logging and OAuth when `true` | +| `DATABASE_HOST` | Yes (DB features) | — | PostgreSQL host (`db` in containers) | +| `LT_SUBDOMAIN_SUFFIX` | Dev only | — | Persistent localtunnel subdomain | + +--- + +## Key Conventions + +### Handler function signature + +All Slack handlers must accept exactly: + +```python +def handler(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): +``` + +### `safe_get(data, *keys)` + +Use instead of chained `dict.get()` calls. Handles dicts, lists (integer keys), and +`SlackResponse` / SQLAlchemy Row objects without raising `KeyError` / `IndexError`. + +### `region_record` + +A `SlackSettings` object containing the workspace's `org_id`, database credentials, and feature +flags. Injected by the routing layer via `get_region_record(team_id)`. + +### Singleton factories + +Infrastructure singletons follow this pattern: + +```python +_client: T | None = None + +def get_instance() -> T: + global _client + if _client is None: + _client = T() + return _client +``` + +### `private_metadata` for modal state + +Pass state between modals by JSON-encoding it in `view.private_metadata`: + +```python +form.update_modal(..., parent_metadata={"edit_event_tag_id": tag.id}) +# read back: +metadata = json.loads(safe_get(body, "view", "private_metadata") or "{}") +``` + +--- + +## Feature Module Reference: Event Tags (canonical example) + +See [API_MIGRATION.md](API_MIGRATION.md) for a detailed breakdown of every layer. + +Files: + +- `application/event_tag/__init__.py` — `EventTagData` +- `application/event_tag/repository.py` — `EventTagRepository` (Protocol) +- `application/event_tag/service.py` — `EventTagService` +- `infrastructure/api_client/event_tag_repository.py` — `ApiEventTagRepository` +- `infrastructure/api_client/client.py` — `F3ApiClient` +- `features/calendar/event_tag.py` — Views, handlers, composition root +- `tests/infrastructure/api_client/test_client.py` +- `tests/infrastructure/api_client/test_event_tag_repository.py` +- `tests/features/calendar/test_event_tag.py` + +--- + +## API Contract Reference + +See [API_REFERENCE.md](API_REFERENCE.md) for the data shapes returned by +the F3 Nation REST API. + +**Active endpoints used by this app:** + +| Domain | Endpoint | Notes | +| --------- | ------------------------------- | ------------------------------------------------------------- | +| Event Tag | `GET /v1/event-tag/org/{orgId}` | Returns global + org-specific tags; filter by `specificOrgId` | +| Event Tag | `GET /v1/event-tag/id/{id}` | Single tag lookup | +| Event Tag | `POST /v1/event-tag` | Create (no `id`) or update (with `id`) | +| Event Tag | `DELETE /v1/event-tag/id/{id}` | Soft delete | + +--- + +## Testing Quick Reference + +```bash +# Run all tests +python -m pytest tests -q + +# Run with coverage +python -m pytest tests --cov=. --cov-report=term-missing -q + +# Run a single file +python -m pytest tests/features/calendar/test_event_tag.py -v +``` + +See [TESTING_STRATEGY.md](TESTING_STRATEGY.md) for the full testing plan. diff --git a/apps/slackbot/docs/TESTING_STRATEGY.md b/apps/slackbot/docs/TESTING_STRATEGY.md new file mode 100644 index 00000000..5b84eed8 --- /dev/null +++ b/apps/slackbot/docs/TESTING_STRATEGY.md @@ -0,0 +1,1785 @@ +# F3 Nation Slack Bot - Testing Strategy + +## Executive Summary + +This document outlines a phased approach to implementing a comprehensive testing suite for the F3 Nation Slack Bot. The current codebase has minimal test coverage (only 2 test files), and this strategy will establish pytest-based testing that works both locally and in GitHub Actions CI/CD pipelines. + +**Current State:** + +- Minimal tests: `test/features/calendar/test_event_tag.py` (unittest-based) and `test/utilities/test_helper_functions.py` +- No test runner configured in `pyproject.toml` +- No CI/CD pipeline for automated testing +- No test fixtures or shared test utilities + +**Target State:** + +- Comprehensive pytest-based test suite with >70% coverage +- Automated testing in GitHub Actions +- Clear test patterns and fixtures for future development +- Fast, reliable tests that can run in isolation + +--- + +## Testing Framework Selection + +**Primary Framework: pytest** + +**Rationale:** + +- Industry standard for Python projects +- Better fixture system than unittest +- More concise test syntax +- Excellent plugin ecosystem (pytest-cov, pytest-mock, pytest-asyncio) +- Compatible with existing unittest tests (can run both) +- Better parameterization support +- Superior output and error reporting + +**Supporting Tools:** + +- `pytest-cov` - Code coverage reporting +- `pytest-mock` - Enhanced mocking capabilities +- `pytest-asyncio` - For any async code testing +- `faker` - Generate realistic test data +- `freezegun` - Time/date mocking +- `responses` - Mock HTTP requests + +--- + +## Test Architecture + +### Test Organization + +``` +test/ +├── conftest.py # Shared fixtures and configuration +├── fixtures/ # Reusable test fixtures +│ ├── __init__.py +│ ├── slack_fixtures.py # Mock Slack clients, events, bodies +│ ├── database_fixtures.py # Mock DB managers, models +│ └── region_fixtures.py # Mock SlackSettings, region data +├── unit/ # Unit tests (isolated) +│ ├── features/ +│ │ ├── test_backblast.py +│ │ ├── test_preblast.py +│ │ ├── calendar/ +│ │ │ ├── test_event_tag.py +│ │ │ ├── test_event_type.py +│ │ │ └── test_home.py +│ │ └── ... +│ ├── utilities/ +│ │ ├── test_helper_functions.py +│ │ ├── test_routing.py +│ │ ├── slack/ +│ │ │ ├── test_sdk_orm.py +│ │ │ └── test_actions.py +│ │ └── database/ +│ │ └── test_orm.py +│ └── scripts/ +│ ├── test_hourly_runner.py +│ └── test_calendar_images.py +├── integration/ # Integration tests +│ ├── test_slack_events.py # End-to-end Slack event flow +│ ├── test_database_operations.py +│ └── test_routing_integration.py +└── e2e/ # End-to-end tests (optional, Phase 4) + └── test_full_workflows.py +``` + +### Test Types + +1. **Unit Tests** - Test individual functions/classes in isolation + - Mock all external dependencies (DB, Slack API, external services) + - Fast execution (<1s per test) + - 80% of test suite + +2. **Integration Tests** - Test component interactions + - Use test database or in-memory SQLite + - Mock only external APIs (Slack, AWS, etc.) + - Medium execution time (1-5s per test) + - 15% of test suite + +3. **End-to-End Tests** - Test complete user workflows (optional) + - Use staging environment or test workspace + - Minimal mocking + - Slow execution (5-30s per test) + - 5% of test suite + +--- + +## Phase 1: Foundation Setup (2-3 hours) + +### Objectives + +- Install and configure pytest +- Create shared test fixtures +- Set up test database configuration +- Migrate existing unittest tests to pytest +- Establish testing conventions + +### Step-by-Step Instructions + +#### 1.1 Install Testing Dependencies + +Add to `pyproject.toml` under `[tool.poetry.group.test.dependencies]`: + +```bash +poetry add --group test pytest pytest-cov pytest-mock pytest-asyncio faker freezegun responses +``` + +Expected additions: + +```toml +[tool.poetry.group.test.dependencies] +pytest = "^8.0.0" +pytest-cov = "^5.0.0" +pytest-mock = "^3.14.0" +pytest-asyncio = "^0.23.0" +faker = "^30.0.0" +freezegun = "^1.5.0" +responses = "^0.25.0" +``` + +Then sync: + +```bash +poetry export -f requirements.txt -o requirements.txt --without-hashes +``` + +#### 1.2 Create pytest Configuration + +Create `pytest.ini` in project root: + +```ini +[pytest] +testpaths = test +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --strict-markers + --cov=. + --cov-report=term-missing + --cov-report=html + --cov-report=xml + --cov-config=.coveragerc + --ignore=test/e2e +markers = + unit: Unit tests (fast, isolated) + integration: Integration tests (use test DB) + e2e: End-to-end tests (slow, require full environment) + slow: Tests that take more than 1 second +``` + +Create `.coveragerc` in project root: + +```ini +[run] +source = . +omit = + */test/* + */tests/* + */__pycache__/* + */site-packages/* + */dist-packages/* + .venv/* + venv/* + */scripts/* + */db-init/* + +[report] +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: + if TYPE_CHECKING: + @abstractmethod +``` + +#### 1.3 Create Test Fixtures + +Create `test/conftest.py`: + +```python +"""Shared pytest fixtures for all tests.""" +import pytest +from unittest.mock import MagicMock, Mock +from logging import Logger + +from utilities.database.orm import SlackSettings + + +@pytest.fixture +def mock_logger(): + """Mock logger for testing.""" + return MagicMock(spec=Logger) + + +@pytest.fixture +def mock_slack_client(): + """Mock Slack WebClient for testing.""" + client = MagicMock() + client.views_open.return_value = {"ok": True} + client.views_update.return_value = {"ok": True} + client.chat_postMessage.return_value = {"ok": True, "ts": "1234567890.123456"} + return client + + +@pytest.fixture +def mock_context(): + """Mock Slack context object.""" + return { + "team_id": "T12345678", + "user_id": "U12345678", + "bot_token": "xoxb-test-token", + } + + +@pytest.fixture +def mock_region_record(): + """Mock SlackSettings/region record.""" + return SlackSettings( + team_id="T12345678", + org_id=1, + workspace_name="Test Workspace", + db_id=1, + bot_token="xoxb-test-token", + email_enabled=0, + postie_format=0, + editing_locked=0, + strava_enabled=0, + welcome_dm_enable=0, + welcome_channel_enable=0, + send_achievements=0, + send_aoq_reports=0, + NO_POST_THRESHOLD=4, + REMINDER_WEEKS=8, + HOME_AO_CAPTURE=1, + ) + + +@pytest.fixture +def sample_slack_body(): + """Sample Slack event body.""" + return { + "type": "block_actions", + "team": {"id": "T12345678", "domain": "test-workspace"}, + "user": { + "id": "U12345678", + "username": "testuser", + "name": "Test User", + }, + "trigger_id": "1234567890.1234567890.abcdef1234567890", + "team_id": "T12345678", + } + + +@pytest.fixture +def sample_view_submission(): + """Sample view submission body.""" + return { + "type": "view_submission", + "team": {"id": "T12345678"}, + "user": {"id": "U12345678"}, + "view": { + "id": "V12345678", + "callback_id": "test-callback-id", + "state": { + "values": {} + }, + "private_metadata": "{}", + }, + } +``` + +Create `test/fixtures/slack_fixtures.py`: + +```python +"""Slack-specific test fixtures.""" +import pytest +from unittest.mock import MagicMock + + +@pytest.fixture +def slack_action_body(sample_slack_body): + """Slack action event body.""" + body = sample_slack_body.copy() + body["actions"] = [ + { + "action_id": "test-action", + "block_id": "test-block", + "value": "test-value", + "type": "button", + } + ] + return body + + +@pytest.fixture +def slack_command_body(): + """Slack slash command body.""" + return { + "token": "test-token", + "team_id": "T12345678", + "team_domain": "test-workspace", + "channel_id": "C12345678", + "channel_name": "general", + "user_id": "U12345678", + "user_name": "testuser", + "command": "/test-command", + "text": "", + "api_app_id": "A12345678", + "trigger_id": "1234567890.1234567890.abcdef1234567890", + } +``` + +Create `test/fixtures/database_fixtures.py`: + +```python +"""Database-related test fixtures.""" +import pytest +from unittest.mock import MagicMock, patch + + +@pytest.fixture +def mock_db_manager(): + """Mock DbManager for testing.""" + with patch("f3_data_models.utils.DbManager") as mock_manager: + # Setup common return values + mock_manager.get.return_value = None + mock_manager.find_records.return_value = [] + mock_manager.create_record.return_value = None + mock_manager.update_record.return_value = None + mock_manager.delete_record.return_value = None + yield mock_manager + + +@pytest.fixture +def mock_org(): + """Mock Org model.""" + from f3_data_models.models import Org + org = MagicMock(spec=Org) + org.id = 1 + org.name = "Test Region" + org.event_tags = [] + org.event_types = [] + org.locations = [] + return org + + +@pytest.fixture +def mock_event_tag(): + """Mock EventTag model.""" + from f3_data_models.models import EventTag + tag = MagicMock(spec=EventTag) + tag.id = 1 + tag.name = "Test Tag" + tag.color = "blue" + tag.specific_org_id = 1 + return tag +``` + +#### 1.4 Migrate Existing Tests + +Refactor `test/utilities/test_helper_functions.py` to use pytest fixtures: + +```python +"""Tests for helper_functions module.""" +import pytest +from utilities.helper_functions import safe_get + + +class TestSafeGet: + """Tests for safe_get function.""" + + def test_nested_dict_access(self): + """Test accessing nested dictionary values.""" + data = {"a": {"b": {"c": 1}}} + assert safe_get(data, "a", "b", "c") == 1 + + def test_missing_key_returns_none(self): + """Test that missing keys return None.""" + data = {"a": {"b": {"c": 1}}} + assert safe_get(data, "a", "b", "d") is None + + def test_none_data_returns_none(self): + """Test that None input returns None.""" + assert safe_get(None, "a", "b") is None + + def test_list_index_access(self): + """Test accessing list elements by index.""" + data = {"items": [1, 2, 3]} + assert safe_get(data, "items", 0) == 1 + assert safe_get(data, "items", 5) is None + + @pytest.mark.parametrize("data,keys,expected", [ + ({"a": 1}, ["a"], 1), + ({"a": {"b": 2}}, ["a", "b"], 2), + ({"a": [1, 2, 3]}, ["a", 1], 2), + ({}, ["a"], None), + ]) + def test_various_access_patterns(self, data, keys, expected): + """Test various data access patterns.""" + assert safe_get(data, *keys) == expected +``` + +#### 1.5 Create Test Running Scripts + +Create `test.sh` (for local testing): + +```bash +#!/bin/bash +# Run tests with coverage + +set -e + +echo "Running pytest with coverage..." +poetry run pytest + +echo "" +echo "Coverage report generated:" +echo " - Terminal: See above" +echo " - HTML: htmlcov/index.html" +echo " - XML: coverage.xml" +``` + +Make executable: + +```bash +chmod +x test.sh +``` + +#### 1.6 Update .gitignore + +Add to `.gitignore`: + +``` +# Testing +.pytest_cache/ +.coverage +htmlcov/ +coverage.xml +.tox/ +``` + +--- + +## Phase 2: Core Utilities Testing (3-4 hours) + +### Objectives + +- Test all utility functions with high coverage +- Establish patterns for testing helper functions +- Test routing logic +- Test Slack ORM wrappers + +### Step-by-Step Instructions + +#### 2.1 Test Helper Functions + +Create `test/unit/utilities/test_helper_functions.py`: + +**Priority Functions to Test:** + +- `safe_get()` - nested data access ✓ (already has basic tests) +- `get_region_record()` - fetch SlackSettings +- `safe_convert()` - type conversion +- `get_location_display_name()` - location formatting +- `trigger_map_revalidation()` - external API call + +Example test structure: + +```python +"""Comprehensive tests for helper_functions module.""" +import pytest +from unittest.mock import patch, MagicMock +from utilities.helper_functions import ( + safe_get, + safe_convert, + get_location_display_name, + get_region_record, + trigger_map_revalidation, +) +from f3_data_models.models import Location + + +@pytest.mark.unit +class TestSafeConvert: + """Tests for safe_convert function.""" + + def test_convert_to_int(self): + """Test converting string to int.""" + assert safe_convert("123", int) == 123 + + def test_convert_none_returns_none(self): + """Test that None returns None.""" + assert safe_convert(None, int) is None + + def test_invalid_conversion_returns_none(self): + """Test that invalid conversions return None.""" + assert safe_convert("abc", int) is None + + @pytest.mark.parametrize("value,target_type,expected", [ + ("123", int, 123), + ("12.5", float, 12.5), + ("true", bool, True), + (None, str, None), + ]) + def test_various_conversions(self, value, target_type, expected): + """Test various type conversions.""" + assert safe_convert(value, target_type) == expected + + +@pytest.mark.unit +class TestGetLocationDisplayName: + """Tests for get_location_display_name function.""" + + def test_returns_name_when_present(self): + """Test that name is returned when available.""" + location = Location(name="Test AO", description="", address_street="") + assert get_location_display_name(location) == "Test AO" + + def test_returns_description_when_no_name(self): + """Test that description is used when name is empty.""" + location = Location(name="", description="Park near the school", address_street="") + assert get_location_display_name(location) == "Park near the school" + + def test_truncates_long_description(self): + """Test that long descriptions are truncated.""" + long_desc = "A" * 50 + location = Location(name="", description=long_desc, address_street="") + result = get_location_display_name(location) + assert len(result) == 30 + + def test_returns_unnamed_location_fallback(self): + """Test fallback for locations with no identifying info.""" + location = Location(name="", description="", address_street="") + assert get_location_display_name(location) == "Unnamed Location" + + +@pytest.mark.unit +class TestGetRegionRecord: + """Tests for get_region_record function.""" + + @patch("utilities.helper_functions.REGION_RECORDS") + def test_returns_cached_record(self, mock_cache, mock_region_record): + """Test that cached region record is returned.""" + mock_cache.__getitem__.return_value = mock_region_record + + result = get_region_record( + "T12345678", + {"team_id": "T12345678"}, + {}, + MagicMock(), + MagicMock(), + ) + + assert result == mock_region_record + + # Add more tests for database lookups, OAuth flows, etc. + + +@pytest.mark.unit +class TestTriggerMapRevalidation: + """Tests for trigger_map_revalidation function.""" + + @patch("utilities.helper_functions.requests.post") + @patch.dict("os.environ", { + "MAP_REVALIDATION_URL": "https://example.com/api/revalidate", + "MAP_REVALIDATION_KEY": "test-key" + }) + def test_successful_revalidation(self, mock_post): + """Test successful map revalidation.""" + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + result = trigger_map_revalidation() + + assert result is True + mock_post.assert_called_once_with( + url="https://example.com/api/revalidate", + headers={ + "Content-Type": "application/json", + "x-api-key": "test-key", + } + ) + + @patch("utilities.helper_functions.requests.post") + def test_failed_revalidation(self, mock_post): + """Test failed map revalidation.""" + mock_post.side_effect = Exception("Network error") + + result = trigger_map_revalidation() + + assert result is False +``` + +#### 2.2 Test Routing Logic + +Create `test/unit/utilities/test_routing.py`: + +```python +"""Tests for routing module.""" +import pytest +from unittest.mock import MagicMock, patch +from utilities.routing import ( + COMMAND_MAPPER, + ACTION_MAPPER, + VIEW_MAPPER, + MAIN_MAPPER, +) +from utilities.slack import actions + + +@pytest.mark.unit +class TestRoutingMappers: + """Tests for routing mapper structures.""" + + def test_command_mapper_structure(self): + """Test that COMMAND_MAPPER has correct structure.""" + for command, (handler, show_loading) in COMMAND_MAPPER.items(): + assert isinstance(command, str) + assert command.startswith("/") + assert callable(handler) + assert isinstance(show_loading, bool) + + def test_action_mapper_structure(self): + """Test that ACTION_MAPPER has correct structure.""" + for action_id, (handler, show_loading) in ACTION_MAPPER.items(): + assert isinstance(action_id, str) + assert callable(handler) + assert isinstance(show_loading, bool) + + def test_view_mapper_structure(self): + """Test that VIEW_MAPPER has correct structure.""" + for callback_id, (handler, show_loading) in VIEW_MAPPER.items(): + assert isinstance(callback_id, str) + assert callable(handler) + assert isinstance(show_loading, bool) + + def test_main_mapper_contains_all_types(self): + """Test that MAIN_MAPPER includes all event types.""" + assert "command" in MAIN_MAPPER + assert "block_actions" in MAIN_MAPPER + assert "view_submission" in MAIN_MAPPER + assert "event_callback" in MAIN_MAPPER + assert "block_suggestion" in MAIN_MAPPER + + def test_all_action_constants_are_mapped(self): + """Test that action constants have handlers.""" + # Get all action constants from actions module + action_constants = [ + getattr(actions, attr) for attr in dir(actions) + if not attr.startswith("_") and isinstance(getattr(actions, attr), str) + ] + + # Check commonly used actions are mapped + important_actions = [ + actions.BACKBLAST_NEW_BUTTON, + actions.PREBLAST_NEW_BUTTON, + actions.CONFIG_GENERAL, + ] + + for action in important_actions: + assert action in ACTION_MAPPER, f"Action {action} not mapped" +``` + +#### 2.3 Test Slack SDK ORM Wrapper + +Create `test/unit/utilities/slack/test_sdk_orm.py`: + +```python +"""Tests for SDK ORM wrapper.""" +import pytest +from unittest.mock import MagicMock, patch +from slack_sdk.models.blocks import InputBlock, SectionBlock +from slack_sdk.models.blocks.block_elements import PlainTextInputElement +from slack_sdk.models.blocks.basic_components import PlainTextObject, Option + +from utilities.slack.sdk_orm import SdkBlockView, as_selector_options + + +@pytest.mark.unit +class TestAsSelectorOptions: + """Tests for as_selector_options helper.""" + + def test_creates_options_from_names(self): + """Test creating options from name list.""" + names = ["Option 1", "Option 2", "Option 3"] + options = as_selector_options(names) + + assert len(options) == 3 + assert all(isinstance(opt, Option) for opt in options) + assert options[0].text.text == "Option 1" + assert options[0].value == "Option 1" + + def test_uses_custom_values(self): + """Test creating options with custom values.""" + names = ["Option 1", "Option 2"] + values = ["val1", "val2"] + options = as_selector_options(names, values) + + assert options[0].value == "val1" + assert options[1].value == "val2" + + def test_adds_descriptions(self): + """Test adding descriptions to options.""" + names = ["Option 1"] + values = ["val1"] + descriptions = ["This is option 1"] + options = as_selector_options(names, values, descriptions) + + assert options[0].description.text == "This is option 1" + + def test_empty_list_returns_no_options_message(self): + """Test that empty list returns placeholder.""" + options = as_selector_options([]) + + assert len(options) == 1 + assert options[0].text.text == "No options available" + + +@pytest.mark.unit +class TestSdkBlockView: + """Tests for SdkBlockView wrapper class.""" + + def test_initialization(self): + """Test initializing with blocks.""" + blocks = [SectionBlock(text="Test")] + view = SdkBlockView(blocks) + + assert len(view.blocks) == 1 + assert view.blocks[0].text == "Test" + + def test_delete_block(self): + """Test deleting a block by ID.""" + blocks = [ + SectionBlock(text="Block 1", block_id="block1"), + SectionBlock(text="Block 2", block_id="block2"), + ] + view = SdkBlockView(blocks) + + view.delete_block("block1") + + assert len(view.blocks) == 1 + assert view.blocks[0].block_id == "block2" + + def test_add_block(self): + """Test adding a block.""" + view = SdkBlockView([]) + new_block = SectionBlock(text="New Block") + + view.add_block(new_block) + + assert len(view.blocks) == 1 + assert view.blocks[0].text == "New Block" + + def test_get_block(self): + """Test retrieving a block by ID.""" + blocks = [SectionBlock(text="Test", block_id="test-block")] + view = SdkBlockView(blocks) + + block = view.get_block("test-block") + + assert block is not None + assert block.text == "Test" + + def test_get_nonexistent_block_returns_none(self): + """Test that getting nonexistent block returns None.""" + view = SdkBlockView([]) + + block = view.get_block("nonexistent") + + assert block is None + + def test_set_initial_values_text_input(self): + """Test setting initial value for text input.""" + input_block = InputBlock( + label=PlainTextObject(text="Name"), + element=PlainTextInputElement(), + block_id="name_input" + ) + view = SdkBlockView([input_block]) + + view.set_initial_values({"name_input": "John Doe"}) + + assert view.blocks[0].element.initial_value == "John Doe" +``` + +--- + +## Phase 3: Feature Module Testing (6-8 hours) + +### Objectives + +- Test Service classes (business logic) +- Test View classes (UI construction) +- Test handler functions (integration) +- Achieve >70% coverage for features + +### Step-by-Step Instructions + +#### 3.1 Test Modern Pattern Feature (Event Tag) + +The existing `test/features/calendar/test_event_tag.py` provides a good template. Enhance it: + +**Create:** `test/unit/features/calendar/test_event_tag.py` + +Improvements needed: + +1. Convert from unittest to pytest +2. Add more edge cases +3. Test error handling +4. Add parameterized tests + +Example enhanced version: + +```python +"""Tests for event_tag feature module.""" +import pytest +from unittest.mock import MagicMock, patch, call +import json + +from f3_data_models.models import EventTag, Org +from features.calendar.event_tag import ( + EventTagService, + EventTagViews, + handle_event_tag_add, + handle_event_tag_edit_delete, + manage_event_tags, + CALENDAR_ADD_EVENT_TAG_SELECT, + CALENDAR_ADD_EVENT_TAG_NEW, + CALENDAR_ADD_EVENT_TAG_COLOR, +) + + +@pytest.mark.unit +class TestEventTagService: + """Tests for EventTagService class.""" + + @patch("features.calendar.event_tag.DbManager") + def test_get_org_event_tags_filters_by_org(self, mock_db, mock_org): + """Test that only org-specific tags are returned.""" + # Setup org with mixed tags + org_tag = EventTag(id=1, name="Org Tag", color="Red", specific_org_id=1) + global_tag = EventTag(id=2, name="Global Tag", color="Blue", specific_org_id=None) + mock_org.event_tags = [org_tag, global_tag] + mock_db.get.return_value = mock_org + + service = EventTagService() + result = service.get_org_event_tags(1) + + # Should only return org-specific tag + assert len(result) == 1 + assert result[0].name == "Org Tag" + + @patch("features.calendar.event_tag.DbManager") + def test_get_available_global_tags_excludes_existing(self, mock_db, mock_org): + """Test that already-added global tags are excluded.""" + # Org already has tag ID 1 + existing_tag = EventTag(id=1, name="Existing", color="Red") + mock_org.event_tags = [existing_tag] + mock_db.get.return_value = mock_org + + # Global tags include 1 and 2 + all_tags = [ + EventTag(id=1, name="Existing", color="Red"), + EventTag(id=2, name="Available", color="Blue"), + ] + mock_db.find_records.return_value = all_tags + + service = EventTagService() + result = service.get_available_global_tags(1) + + # Should only return tag ID 2 + assert len(result) == 1 + assert result[0].id == 2 + + @patch("features.calendar.event_tag.DbManager") + def test_create_org_specific_tag_validation(self, mock_db): + """Test creating org-specific tag with validation.""" + service = EventTagService() + service.create_org_specific_tag("VQ", "green", 1) + + # Verify create_record was called + mock_db.create_record.assert_called_once() + + # Verify the created tag has correct attributes + created_tag = mock_db.create_record.call_args[0][0] + assert isinstance(created_tag, EventTag) + assert created_tag.name == "VQ" + assert created_tag.color == "green" + assert created_tag.specific_org_id == 1 + + @patch("features.calendar.event_tag.DbManager") + def test_update_org_specific_tag(self, mock_db): + """Test updating an existing tag.""" + service = EventTagService() + service.update_org_specific_tag(1, "Updated Name", "yellow") + + mock_db.update_record.assert_called_once_with( + EventTag, + 1, + {EventTag.name: "Updated Name", EventTag.color: "yellow"} + ) + + @patch("features.calendar.event_tag.DbManager") + def test_delete_org_specific_tag(self, mock_db): + """Test deleting a tag.""" + service = EventTagService() + service.delete_org_specific_tag(5) + + mock_db.delete_record.assert_called_once_with(EventTag, 5) + + +@pytest.mark.unit +class TestEventTagViews: + """Tests for EventTagViews class.""" + + def test_build_add_tag_modal_structure(self): + """Test modal has correct structure.""" + available_tags = [EventTag(id=2, name="Available", color="Blue")] + org_tags = [] + + views = EventTagViews() + form = views.build_add_tag_modal(available_tags, org_tags) + + # Should have expected number of blocks + assert len(form.blocks) > 0 + + # Should have selector with available tags + selector_block = form.get_block(CALENDAR_ADD_EVENT_TAG_SELECT) + assert selector_block is not None + + def test_build_edit_tag_modal_populates_values(self, mock_event_tag): + """Test that edit modal is pre-populated.""" + views = EventTagViews() + form = views.build_edit_tag_modal(mock_event_tag, []) + + # Should have initial values set + # (This tests the set_initial_values call) + assert form.get_block(CALENDAR_ADD_EVENT_TAG_NEW) is not None + assert form.get_block(CALENDAR_ADD_EVENT_TAG_COLOR) is not None + + def test_build_tag_list_modal_creates_edit_buttons(self): + """Test that tag list has edit button for each tag.""" + org_tags = [ + EventTag(id=1, name="Tag 1", color="Red"), + EventTag(id=2, name="Tag 2", color="Blue"), + ] + + views = EventTagViews() + form = views.build_tag_list_modal(org_tags) + + # Should have block for each tag (plus context block) + assert len(form.blocks) == 3 + + +@pytest.mark.unit +class TestEventTagHandlers: + """Tests for event_tag handler functions.""" + + @patch("features.calendar.event_tag.EventTagService") + @patch("features.calendar.event_tag.EventTagViews") + def test_manage_event_tags_add_action( + self, + mock_views_class, + mock_service_class, + slack_action_body, + mock_slack_client, + mock_logger, + mock_context, + mock_region_record, + ): + """Test manage_event_tags with 'add' action.""" + # Setup body for "add" action + slack_action_body["actions"][0]["selected_option"] = {"value": "add"} + + # Setup mocks + mock_service = MagicMock() + mock_service.get_available_global_tags.return_value = [] + mock_service.get_org_event_tags.return_value = [] + mock_service_class.return_value = mock_service + + mock_views = MagicMock() + mock_form = MagicMock() + mock_views.build_add_tag_modal.return_value = mock_form + mock_views_class.return_value = mock_views + + # Execute + manage_event_tags( + slack_action_body, + mock_slack_client, + mock_logger, + mock_context, + mock_region_record, + ) + + # Verify service calls + mock_service.get_available_global_tags.assert_called_once_with( + mock_region_record.org_id + ) + mock_service.get_org_event_tags.assert_called_once_with( + mock_region_record.org_id + ) + + # Verify modal was posted + mock_form.post_modal.assert_called_once() + + @patch("features.calendar.event_tag.EVENT_TAG_FORM") + @patch("features.calendar.event_tag.EventTagService") + def test_handle_event_tag_add_creates_new_tag( + self, + mock_service_class, + mock_form, + sample_view_submission, + mock_slack_client, + mock_logger, + mock_context, + mock_region_record, + ): + """Test creating a new tag via form submission.""" + # Setup form values + mock_form.get_selected_values.return_value = { + CALENDAR_ADD_EVENT_TAG_NEW: "New Tag", + CALENDAR_ADD_EVENT_TAG_COLOR: "purple", + CALENDAR_ADD_EVENT_TAG_SELECT: None, + } + + mock_service = MagicMock() + mock_service_class.return_value = mock_service + + # Execute + handle_event_tag_add( + sample_view_submission, + mock_slack_client, + mock_logger, + mock_context, + mock_region_record, + ) + + # Verify service method called + mock_service.create_org_specific_tag.assert_called_once_with( + "New Tag", + "purple", + mock_region_record.org_id + ) + + @pytest.mark.parametrize("action_value,expected_method", [ + ("Edit", "build_edit_tag_modal"), + ("Delete", "delete_org_specific_tag"), + ]) + @patch("features.calendar.event_tag.DbManager") + @patch("features.calendar.event_tag.EventTagService") + @patch("features.calendar.event_tag.EventTagViews") + def test_handle_event_tag_edit_delete_actions( + self, + mock_views_class, + mock_service_class, + mock_db, + action_value, + expected_method, + slack_action_body, + mock_slack_client, + mock_logger, + mock_context, + mock_region_record, + mock_event_tag, + ): + """Test edit and delete actions.""" + # Setup action body + slack_action_body["actions"][0]["action_id"] = "event-tag-edit-delete_5" + slack_action_body["actions"][0]["selected_option"] = {"value": action_value} + + mock_db.get.return_value = mock_event_tag + mock_service = MagicMock() + mock_service_class.return_value = mock_service + mock_views = MagicMock() + mock_views_class.return_value = mock_views + + # Execute + handle_event_tag_edit_delete( + slack_action_body, + mock_slack_client, + mock_logger, + mock_context, + mock_region_record, + ) + + # Verify appropriate method was called + if action_value == "Edit": + mock_views.build_edit_tag_modal.assert_called_once() + else: + mock_service.delete_org_specific_tag.assert_called_once_with(5) +``` + +#### 3.2 Create Test Template for Legacy Features + +Create `test/unit/features/test_backblast.py`: + +**Test Structure for Legacy Features:** + +1. Test build functions (modal construction) +2. Test handle functions (form processing) +3. Mock all external dependencies +4. Test error handling + +Example structure: + +```python +"""Tests for backblast feature module.""" +import pytest +from unittest.mock import MagicMock, patch, ANY +from features import backblast +from utilities.slack import actions + + +@pytest.mark.unit +class TestBackblastMiddleware: + """Tests for backblast_middleware function.""" + + @patch("features.backblast.build_backblast_form") + def test_middleware_calls_build_form( + self, + mock_build, + slack_command_body, + mock_slack_client, + mock_logger, + mock_context, + mock_region_record, + ): + """Test that middleware calls build_backblast_form.""" + backblast.backblast_middleware( + slack_command_body, + mock_slack_client, + mock_logger, + mock_context, + mock_region_record, + ) + + mock_build.assert_called_once() + + +@pytest.mark.unit +class TestBuildBackblastForm: + """Tests for build_backblast_form function.""" + + @patch("features.backblast.DbManager") + def test_form_includes_required_fields( + self, + mock_db, + slack_command_body, + mock_slack_client, + mock_logger, + mock_context, + mock_region_record, + ): + """Test that backblast form includes all required fields.""" + mock_db.find_records.return_value = [] + + backblast.build_backblast_form( + slack_command_body, + mock_slack_client, + mock_logger, + mock_context, + mock_region_record, + ) + + # Verify modal was opened + mock_slack_client.views_open.assert_called_once() + + # Verify view structure contains required blocks + call_args = mock_slack_client.views_open.call_args + view = call_args[1]["view"] + + # Should have blocks for title, AO, date, Q, PAX, etc. + assert "blocks" in view + assert len(view["blocks"]) > 0 + + # Add more tests for: + # - Pre-filling from message context + # - Loading AO list + # - Custom fields + # - Duplicate detection + + +@pytest.mark.unit +class TestHandleBackblastPost: + """Tests for handle_backblast_post function.""" + + @patch("features.backblast.DbManager") + @patch("features.backblast.post_backblast_to_channel") + def test_posts_to_channel( + self, + mock_post, + mock_db, + sample_view_submission, + mock_slack_client, + mock_logger, + mock_context, + mock_region_record, + ): + """Test that backblast is posted to channel.""" + # Setup view values + sample_view_submission["view"]["state"]["values"] = { + actions.BACKBLAST_TITLE: { + actions.BACKBLAST_TITLE: {"value": "Test Workout"} + }, + # Add more required fields... + } + + mock_db.create_record.return_value = None + + backblast.handle_backblast_post( + sample_view_submission, + mock_slack_client, + mock_logger, + mock_context, + mock_region_record, + ) + + # Verify post function was called + mock_post.assert_called_once() + + # Add more tests for: + # - Database record creation + # - Email sending (if enabled) + # - Error handling + # - Validation failures +``` + +#### 3.3 Prioritize Features for Testing + +**High Priority** (core user flows): + +1. `features/backblast.py` - Critical user feature +2. `features/preblast.py` - Critical user feature +3. `features/calendar/home.py` - Main calendar interface +4. `features/config.py` - Settings management + +**Medium Priority**: 5. `features/calendar/event_instance.py` 6. `features/calendar/event_type.py` 7. `features/calendar/ao.py` 8. `features/calendar/location.py` + +**Lower Priority**: 9. `features/strava.py` - Optional integration 10. `features/weaselbot.py` - Achievement system 11. `features/custom_fields.py` + +--- + +## Phase 4: Integration & Script Testing (3-4 hours) + +### Objectives + +- Test integration between components +- Test script automation jobs +- Test main.py routing flow + +### Step-by-Step Instructions + +#### 4.1 Create Integration Test Database + +Add to `conftest.py`: + +```python +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, scoped_session +from f3_data_models.models import Base + + +@pytest.fixture(scope="session") +def test_db_engine(): + """Create in-memory SQLite database for testing.""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + yield engine + Base.metadata.drop_all(engine) + + +@pytest.fixture(scope="function") +def test_db_session(test_db_engine): + """Create a new database session for a test.""" + Session = scoped_session(sessionmaker(bind=test_db_engine)) + session = Session() + yield session + session.rollback() + session.close() +``` + +#### 4.2 Integration Tests + +Create `test/integration/test_routing_integration.py`: + +```python +"""Integration tests for routing and event handling.""" +import pytest +from unittest.mock import patch, MagicMock +from main import main_response +from utilities.slack import actions + + +@pytest.mark.integration +class TestRoutingIntegration: + """Test that events are properly routed to handlers.""" + + @patch("features.backblast.build_backblast_form") + def test_slash_command_routing( + self, + mock_handler, + slack_command_body, + mock_slack_client, + mock_logger, + mock_context, + ): + """Test that slash commands route correctly.""" + slack_command_body["command"] = "/backblast" + ack = MagicMock() + + with patch("main.get_region_record") as mock_get_region: + mock_get_region.return_value = MagicMock(team_id="T12345678") + + main_response( + slack_command_body, + mock_logger, + mock_slack_client, + ack, + mock_context, + ) + + # Verify handler was called + mock_handler.assert_called_once() + ack.assert_called_once() + + @patch("features.calendar.event_tag.manage_event_tags") + def test_button_action_routing( + self, + mock_handler, + slack_action_body, + mock_slack_client, + mock_logger, + mock_context, + ): + """Test that button actions route correctly.""" + slack_action_body["actions"][0]["action_id"] = actions.CALENDAR_MANAGE_EVENT_TAGS + ack = MagicMock() + + with patch("main.get_region_record") as mock_get_region: + mock_get_region.return_value = MagicMock(team_id="T12345678") + + main_response( + slack_action_body, + mock_logger, + mock_slack_client, + ack, + mock_context, + ) + + mock_handler.assert_called_once() +``` + +#### 4.3 Script Testing + +Create `test/unit/scripts/test_hourly_runner.py`: + +```python +"""Tests for hourly_runner script.""" +import pytest +from unittest.mock import patch, MagicMock +from scripts import hourly_runner + + +@pytest.mark.unit +class TestHourlyRunner: + """Tests for run_all_hourly_scripts function.""" + + @patch("scripts.hourly_runner.calendar_images.generate_calendar_images") + @patch("scripts.hourly_runner.backblast_reminders.send_backblast_reminders") + @patch("scripts.hourly_runner.preblast_reminders.send_preblast_reminders") + def test_runs_all_scripts( + self, + mock_preblast, + mock_backblast, + mock_calendar, + ): + """Test that all hourly scripts are executed.""" + hourly_runner.run_all_hourly_scripts(run_reporting=False) + + mock_calendar.assert_called_once() + mock_backblast.assert_called_once() + mock_preblast.assert_called_once() + + @patch("scripts.hourly_runner.calendar_images.generate_calendar_images") + def test_continues_on_script_failure(self, mock_calendar): + """Test that script continues if one job fails.""" + mock_calendar.side_effect = Exception("Test error") + + # Should not raise exception + hourly_runner.run_all_hourly_scripts(run_reporting=False) +``` + +--- + +## Phase 5: CI/CD Pipeline Setup (1-2 hours) + +### Objectives + +- Create GitHub Actions workflow +- Configure test running in CI +- Set up coverage reporting +- Add status badges + +### Step-by-Step Instructions + +#### 5.1 Create GitHub Actions Workflow + +Create `.github/workflows/test.yml`: + +```yaml +name: Test Suite + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: f3_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: 1.7.1 + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v4 + with: + path: .venv + key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install dependencies + if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' + run: poetry install --no-interaction --with test + + - name: Set up test environment + env: + DATABASE_HOST: localhost + DATABASE_PORT: 5432 + DATABASE_USER: postgres + DATABASE_PASSWORD: postgres + DATABASE_NAME: f3_test + LOCAL_DEVELOPMENT: true + SLACK_SIGNING_SECRET: test-secret + SLACK_BOT_TOKEN: xoxb-test-token + run: | + echo "DATABASE_HOST=localhost" >> .env + echo "DATABASE_PORT=5432" >> .env + echo "LOCAL_DEVELOPMENT=true" >> .env + echo "SLACK_SIGNING_SECRET=test-secret" >> .env + echo "SLACK_BOT_TOKEN=xoxb-test-token" >> .env + + - name: Run tests + run: | + poetry run pytest -v --cov --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + - name: Generate coverage badge + if: github.ref == 'refs/heads/main' + run: | + poetry run coverage-badge -o coverage.svg -f + + - name: Upload coverage badge + if: github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v4 + with: + name: coverage-badge + path: coverage.svg + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Poetry + uses: snok/install-poetry@v1 + + - name: Install dependencies + run: poetry install --no-interaction + + - name: Run Ruff + run: poetry run ruff check . + + - name: Check formatting + run: poetry run ruff format --check . +``` + +#### 5.2 Create Pre-commit Hook for Tests + +Update `.pre-commit-config.yaml` (or create if doesn't exist): + +```yaml +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: local + hooks: + - id: pytest-check + name: pytest-check + entry: poetry run pytest -v --tb=short + language: system + pass_filenames: false + always_run: true + stages: [push] +``` + +#### 5.3 Add Coverage Badge to README + +Add to `README.md`: + +```markdown +[![Test Suite](https://github.com/F3-Nation/f3-nation-slack-bot/actions/workflows/test.yml/badge.svg)](https://github.com/F3-Nation/f3-nation-slack-bot/actions/workflows/test.yml) +[![codecov](https://codecov.io/gh/F3-Nation/f3-nation-slack-bot/branch/main/graph/badge.svg)](https://codecov.io/gh/F3-Nation/f3-nation-slack-bot) +``` + +--- + +## Phase 6: Maintenance & Best Practices (Ongoing) + +### Testing Conventions + +#### 1. Test Naming + +- Test files: `test_.py` +- Test classes: `Test` +- Test functions: `test_` +- Use descriptive names that explain what's being tested + +#### 2. Test Structure (AAA Pattern) + +```python +def test_something(): + # Arrange - Set up test data and mocks + data = {"key": "value"} + mock_service = MagicMock() + + # Act - Execute the code being tested + result = function_under_test(data, mock_service) + + # Assert - Verify the results + assert result == expected_value + mock_service.method.assert_called_once() +``` + +#### 3. Test Markers + +Use pytest markers to categorize tests: + +```python +@pytest.mark.unit # Fast, isolated unit tests +@pytest.mark.integration # Tests with database/external dependencies +@pytest.mark.slow # Tests that take >1 second +@pytest.mark.e2e # End-to-end tests +``` + +Run specific test types: + +```bash +poetry run pytest -m unit # Only unit tests +poetry run pytest -m "not slow" # Skip slow tests +``` + +#### 4. Mocking Strategy + +- Mock external dependencies (database, Slack API, AWS, etc.) +- Don't mock the code you're testing +- Use `patch` as context manager or decorator +- Verify mock calls with `assert_called_once_with()` + +#### 5. Test Data + +- Use fixtures for reusable test data +- Use `faker` for realistic random data +- Keep test data minimal but representative + +#### 6. Coverage Goals + +- **Overall**: >70% coverage +- **Critical paths**: >90% coverage (routing, data processing) +- **Utilities**: >80% coverage +- **Don't chase 100%** - some code (error handlers, edge cases) is hard to test + +#### 7. Running Tests Locally + +```bash +# Run all tests +poetry run pytest + +# Run specific test file +poetry run pytest test/unit/utilities/test_helper_functions.py + +# Run specific test +poetry run pytest test/unit/utilities/test_helper_functions.py::TestSafeGet::test_nested_dict_access + +# Run with coverage +poetry run pytest --cov + +# Run fast tests only +poetry run pytest -m "not slow" + +# Run with verbose output +poetry run pytest -v -s + +# Run tests matching pattern +poetry run pytest -k "test_backblast" +``` + +--- + +## Success Criteria + +### Phase Completion Checklist + +**Phase 1 - Foundation** + +- [ ] pytest installed and configured +- [ ] `conftest.py` with shared fixtures created +- [ ] `.coveragerc` and `pytest.ini` configured +- [ ] Existing tests migrated to pytest +- [ ] Tests run successfully locally + +**Phase 2 - Core Utilities** + +- [ ] Helper functions tested (>80% coverage) +- [ ] Routing logic tested +- [ ] Slack ORM tested +- [ ] All utility modules have test files + +**Phase 3 - Features** + +- [ ] Event tag tests comprehensive +- [ ] Backblast feature tested +- [ ] Preblast feature tested +- [ ] Calendar home tested +- [ ] High priority features >70% coverage + +**Phase 4 - Integration** + +- [ ] Routing integration tests created +- [ ] Script tests created +- [ ] Integration test database configured +- [ ] End-to-end test approach defined + +**Phase 5 - CI/CD** + +- [ ] GitHub Actions workflow created +- [ ] Tests run automatically on PRs +- [ ] Coverage reporting integrated +- [ ] Status badges added to README + +**Phase 6 - Maintenance** + +- [ ] Testing guidelines documented +- [ ] Team trained on testing approach +- [ ] Tests maintained with new features + +--- + +## Estimated Timeline + +| Phase | Time Estimate | Priority | +| ------------------------ | --------------- | ----------- | +| Phase 1: Foundation | 2-3 hours | Must have | +| Phase 2: Core Utilities | 3-4 hours | Must have | +| Phase 3: Feature Testing | 6-8 hours | Must have | +| Phase 4: Integration | 3-4 hours | Should have | +| Phase 5: CI/CD Pipeline | 1-2 hours | Must have | +| Phase 6: Documentation | 1 hour | Should have | +| **Total** | **16-22 hours** | | + +--- + +## Common Pitfalls & Solutions + +### Pitfall 1: Over-mocking + +**Problem**: Mocking so much that tests don't catch real bugs +**Solution**: Mock external dependencies only, test real logic + +### Pitfall 2: Slow Tests + +**Problem**: Tests take too long, developers skip them +**Solution**: Use in-memory database, mark slow tests, parallelize + +### Pitfall 3: Flaky Tests + +**Problem**: Tests pass/fail randomly +**Solution**: Avoid time-dependent tests, use `freezegun`, fix race conditions + +### Pitfall 4: Testing Implementation, Not Behavior + +**Problem**: Tests break when refactoring +**Solution**: Test public interfaces, not internal implementation + +### Pitfall 5: No Test Maintenance + +**Problem**: Tests become outdated and ignored +**Solution**: Treat tests as production code, update with features + +--- + +## Next Steps + +After completing this strategy: + +1. **Start with Phase 1** - Get the foundation right +2. **Focus on high-value tests first** - Core features over edge cases +3. **Iterate and improve** - Add tests incrementally +4. **Measure progress** - Track coverage over time +5. **Make it a habit** - Write tests for all new features + +**Ready to begin?** Start with Phase 1, Section 1.1: Install Testing Dependencies. diff --git a/apps/slackbot/docs/copilot-instructions.md b/apps/slackbot/docs/copilot-instructions.md new file mode 100644 index 00000000..5df7ab11 --- /dev/null +++ b/apps/slackbot/docs/copilot-instructions.md @@ -0,0 +1,176 @@ +# F3 Nation Slack Bot - AI Coding Agent Guide + +## Architecture Overview + +This is a **Slack Bolt Python app** deployed to Google Cloud Run that manages F3 fitness community operations. The app uses an **event-driven routing architecture** where all Slack interactions flow through a single entrypoint (`main.py`) and are dispatched to feature modules via `utilities/routing.py`. + +**Key architectural pattern**: Slack events → `main_response()` → routing lookup → feature handler (build/handle functions) + +### Data Flow + +1. Slack sends event to `/slack` endpoint (handled by `@functions_framework.http`) +2. `main_response()` extracts request type and ID via `get_request_type()` +3. `MAIN_MAPPER` in `utilities/routing.py` routes to appropriate feature function +4. Functions receive standard signature: `(body, client, logger, context, region_record)` +5. Feature functions build/update Slack modals or handle form submissions + +## Critical Development Workflows + +### Local Development Setup + +```bash +# Use VS Code Dev Containers (recommended) +# 1. Ensure Docker and Dev Container extension installed +# 2. Open folder in container (auto-builds db, db-init, app services) + +# Start the app +./app_startup.sh # Starts localtunnel + watchfiles auto-reload + +# App runs on http://localhost:3000 +# Localtunnel provides public URL (saved to .env as LT_SUBDOMAIN_SUFFIX) +``` + +**Important**: `app_startup.sh` auto-generates `app_manifest.json` from `app_manifest.template.json` with the localtunnel URL. Update your Slack app manifest via the web console after starting. + +### Database Architecture + +- Uses **SQLAlchemy** with models from external package `f3-data-models` (version ^0.8.0) +- Access via `DbManager` from `f3_data_models.utils` +- Two ORMs in play: + - `utilities/database/orm/__init__.py` - defines `SlackSettings` dataclass + - `f3-data-models` package - defines main models (Org, User, EventTag, etc.) +- PostgreSQL runs on `localhost:5433` (mapped from container port 5432) +- Migrations handled by `db-init` service which clones F3-Data-Models repo and runs Alembic + +**Pattern**: Always use `DbManager.get()`, `DbManager.find_records()`, `DbManager.create_record()`, etc. Never raw SQL. + +### Testing & Code Quality + +```bash +# Managed by poetry + pre-commit hooks +poetry install # Install dependencies +poetry export -f requirements.txt -o requirements.txt --without-hashes # Sync requirements.txt after adding packages + +# Linting with Ruff (line-length: 120) +# Pre-commit hooks auto-run on commit +# isort orders: future → standard-library → third-party → first-party → local-folder +``` + +## Project-Specific Patterns + +### Routing Pattern + +Every new Slack interaction **must** be registered in `utilities/routing.py`: + +```python +ACTION_MAPPER = { + actions.CALENDAR_MANAGE_EVENT_TAGS: (event_tag.manage_event_tags, False), + # Format: "action_id": (handler_function, show_loading_modal) +} +``` + +The boolean flag controls whether a loading modal appears before the handler runs. + +**Action ID constants** live in `utilities/slack/actions.py` - centralized string constants prevent typos. + +### Feature Module Design Pattern + +**Modern pattern** (see `features/calendar/event_tag.py`): + +1. **Service class** - business logic (e.g., `EventTagService`) +2. **Views class** - Slack UI construction (e.g., `EventTagViews`) +3. **Handler functions** - orchestrate service + views, registered in routing +4. **Module-level constants** for action IDs + +**Legacy pattern** (older modules): Build functions, handle functions, and UI mixed in single file. Refactor new code to modern pattern. + +### Slack UI Construction + +**Two approaches coexist**: + +1. **Legacy custom ORM** (`utilities/slack/orm.py`): Custom `InputBlock`, `BaseElement` classes with `.as_form_field()` method + - Used in older features + - Eventually being phased out + +2. **Slack SDK ORM** (`utilities/slack/sdk_orm.py`): Uses `slack_sdk.models.blocks` directly + - Preferred for new code (see `features/calendar/event_tag.py`) + - Wrapper class `SdkBlockView` provides helpers like `set_initial_values()`, `get_selected_value()` + - Helper function `as_selector_options()` converts lists to Slack Option objects + +**Pattern**: Use `SdkBlockView` for new features, gradually migrate legacy ORM usage. + +### Helper Functions & Utilities + +**Critical utilities in `utilities/helper_functions.py`**: + +- `safe_get(data, *keys)` - nested dict access without KeyErrors (handles dicts, lists, SlackResponse, SQLAlchemy Rows) +- `get_region_record(team_id, ...)` - fetches `SlackSettings` for a workspace +- `REGION_RECORDS` - in-memory cache of SlackSettings by team_id +- `update_local_region_records()` - refreshes cache (called by hourly runner) + +### Scripts & Automation + +**Hourly jobs** in `scripts/`: + +- Entrypoint: `scripts/hourly_runner.py` - runs all scheduled tasks +- Separate Docker image (see `scripts/Dockerfile`) with heavy deps (Playwright, pandas, plotting libs) +- Deployed as Cloud Run Job +- Calls back to main app via `/hourly-runner-complete` webhook + +**Key scripts**: + +- `calendar_images.py` - generates calendar visuals +- `backblast_reminders.py` / `preblast_reminders.py` - sends reminders +- `q_lineups.py` - Q assignment notifications +- `monthly_reporting.py` - analytics reports + +## External Dependencies & Integration + +### F3 Data Models Package + +External package managing database schema. Located at: `https://github.com/F3-Nation/F3-Data-Models` + +**Key models**: Org, User, EventTag, EventType, EventInstance, AO, Location, SlackUser, SlackSpace + +**Important**: Future architecture will use an API instead of direct DB access. Design for this transition. + +### Slack Integration + +- Uses `slack-bolt` 1.22.0 with `SlackRequestHandler` adapter for Flask/Functions Framework +- OAuth handled by `FileInstallationStore` (mounted volume in production) +- `process_before_response=True` in production for faster ack + +### Environment Configuration + +Required variables in `.env`: + +- `SLACK_SIGNING_SECRET`, `SLACK_BOT_TOKEN` (from Slack app console) +- `LOCAL_DEVELOPMENT=true` (disables Cloud Logging, OAuth) +- `DATABASE_HOST=db` (container networking) +- `LT_SUBDOMAIN_SUFFIX` (auto-generated, persistent subdomain for localtunnel) + +## Common Pitfalls + +1. **Adding new actions**: Always add constant to `utilities/slack/actions.py` + register in `utilities/routing.py` mapper +2. **Poetry vs requirements.txt**: After `poetry add`, must manually run export command to sync `requirements.txt` +3. **Database port**: Use `5433` externally (5432 is container internal) +4. **Loading modals**: Set boolean flag in routing mapper to show/hide loading indicator +5. **Form submissions**: Use `SdkBlockView.get_selected_values()` to extract form data from submission body +6. **Region context**: Most handlers need `region_record` for org_id and workspace settings + +## File Organization + +- `features/` - core functionality (backblast, preblast, calendar, config, etc.) +- `features/calendar/` - calendar-specific features (modular design) +- `utilities/` - shared helpers, routing, builders, constants +- `utilities/slack/` - Slack-specific abstractions (actions, forms, ORM) +- `utilities/database/` - DB utilities and ORM definitions +- `scripts/` - scheduled jobs (separate deployment) +- `test/` - pytest tests (limited coverage currently) + +## Future Considerations + +- **API migration**: Direct DB access will be replaced by API calls +- **Full ORM migration**: Move all code to use `slack_sdk.models` directly +- **Testing**: Expand test coverage, especially for critical feature paths +- **Modularization**: Refactor legacy features to Service/Views/Handler pattern diff --git a/apps/slackbot/features/__init__.py b/apps/slackbot/features/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/slackbot/features/achievements.py b/apps/slackbot/features/achievements.py new file mode 100644 index 00000000..8f6d2368 --- /dev/null +++ b/apps/slackbot/features/achievements.py @@ -0,0 +1,1058 @@ +""" +Achievement management feature module. + +This module provides: +- Achievement configuration (enable/disable, channel selection) +- Create, edit, delete custom achievements for a region +- Manual achievement tagging +""" + +import copy +import json +from datetime import datetime +from logging import Logger +from typing import Any, Dict, List, Optional + +import pytz +from f3_data_models.models import ( + Achievement, + Achievement_Cadence, + Achievement_x_User, + EventTag, + EventType, + SlackSpace, +) +from f3_data_models.utils import DbManager +from slack_sdk.models.blocks import ( + ContextBlock, + DividerBlock, + HeaderBlock, + InputBlock, + SectionBlock, +) +from slack_sdk.models.blocks.basic_components import MarkdownTextObject, Option, PlainTextObject +from slack_sdk.models.blocks.block_elements import ( + ButtonElement, + ChannelSelectElement, + CheckboxesElement, + NumberInputElement, + OverflowMenuElement, + PlainTextInputElement, + RadioButtonsElement, + StaticMultiSelectElement, + StaticSelectElement, +) +from slack_sdk.web import WebClient +from sqlalchemy import or_ + +from utilities.builders import add_loading_form +from utilities.constants import ACHIEVEMENTS_ALPHA_TESTING_ORG_IDS +from utilities.database.orm import SlackSettings +from utilities.helper_functions import ( + current_date_cst, + get_user, + safe_convert, + safe_get, + update_local_region_records, +) +from utilities.slack.sdk_orm import SdkBlockView, as_selector_options + +# ============================================================================= +# Action IDs +# ============================================================================= +# Config modal +ACHIEVEMENT_CONFIG_CALLBACK_ID = "achievement-config-id" +ACHIEVEMENT_CONFIG_ENABLE = "achievement-config-enable" +ACHIEVEMENT_CONFIG_CHANNEL = "achievement-config-channel" +ACHIEVEMENT_CONFIG_SEND_OPTION = "achievement-config-send-option" +ACHIEVEMENT_CONFIG_NEW_BTN = "achievement-config-new-btn" +ACHIEVEMENT_CONFIG_MANAGE_BTN = "achievement-config-manage-btn" +ACHIEVEMENT_CONFIG_ACHIEVEMENTS_LIST = "achievement-config-list" + +# New/Edit achievement modal +ACHIEVEMENT_NEW_CALLBACK_ID = "achievement-new-id" +ACHIEVEMENT_NEW_NAME = "achievement-new-name" +ACHIEVEMENT_NEW_DESCRIPTION = "achievement-new-description" +ACHIEVEMENT_NEW_IMAGE = "achievement-new-image" +ACHIEVEMENT_NEW_AUTO_MANUAL = "achievement-new-auto-manual" +ACHIEVEMENT_NEW_PERIOD = "achievement-new-period" +ACHIEVEMENT_NEW_METRIC = "achievement-new-metric" +ACHIEVEMENT_NEW_THRESHOLD = "achievement-new-threshold" +ACHIEVEMENT_NEW_FILTER_INCLUDE_CATEGORY = "achievement-new-filter-inc-cat" +ACHIEVEMENT_NEW_FILTER_INCLUDE_EVENT_TYPE = "achievement-new-filter-inc-type" +ACHIEVEMENT_NEW_FILTER_INCLUDE_EVENT_TAG = "achievement-new-filter-inc-tag" +ACHIEVEMENT_NEW_FILTER_EXCLUDE_CATEGORY = "achievement-new-filter-exc-cat" +ACHIEVEMENT_NEW_FILTER_EXCLUDE_EVENT_TYPE = "achievement-new-filter-exc-type" +ACHIEVEMENT_NEW_FILTER_EXCLUDE_EVENT_TAG = "achievement-new-filter-exc-tag" + +# Manage achievements modal +ACHIEVEMENT_MANAGE_CALLBACK_ID = "achievement-manage-id" +ACHIEVEMENT_MANAGE_OVERFLOW = "achievement-manage-overflow" + +# Tag achievement modal (manual tagging) +ACHIEVEMENT_TAG_CALLBACK_ID = "achievement-tag-id" +ACHIEVEMENT_TAG_SELECT = "achievement-tag-select" +ACHIEVEMENT_TAG_PAX = "achievement-tag-pax" +ACHIEVEMENT_TAG_DATE = "achievement-tag-date" + + +# ============================================================================= +# Service Class - Business Logic +# ============================================================================= +class AchievementService: + """Service class for achievement business logic.""" + + @staticmethod + def get_all_achievements(org_id: int) -> List[Achievement]: + """Get all active achievements (region-specific + global).""" + return DbManager.find_records( + Achievement, + filters=[ + Achievement.is_active, + or_(Achievement.specific_org_id == org_id, Achievement.specific_org_id.is_(None)), + ], + ) + + @staticmethod + def get_region_achievements(org_id: int) -> List[Achievement]: + """Get only region-specific achievements.""" + return DbManager.find_records( + Achievement, + filters=[ + Achievement.is_active, + Achievement.specific_org_id == org_id, + ], + ) + + @staticmethod + def get_achievement(achievement_id: int) -> Achievement: + """Get a single achievement by ID.""" + return DbManager.get(Achievement, achievement_id) + + @staticmethod + def create_achievement( + name: str, + org_id: int, + description: Optional[str] = None, + image_url: Optional[str] = None, + auto_award: bool = False, + auto_cadence: Optional[Achievement_Cadence] = None, + auto_threshold: Optional[int] = None, + auto_threshold_type: Optional[str] = None, + auto_filters: Optional[Dict[str, Any]] = None, + ) -> Achievement: + """Create a new region-specific achievement.""" + achievement = Achievement( + name=name, + description=description, + image_url=image_url, + specific_org_id=org_id, + auto_award=auto_award, + auto_cadence=auto_cadence, + auto_threshold=auto_threshold, + auto_threshold_type=auto_threshold_type, + auto_filters=auto_filters or {}, + ) + return DbManager.create_record(achievement) + + @staticmethod + def update_achievement( + achievement_id: int, + name: Optional[str] = None, + description: Optional[str] = None, + image_url: Optional[str] = None, + auto_award: Optional[bool] = None, + auto_cadence: Optional[Achievement_Cadence] = None, + auto_threshold: Optional[int] = None, + auto_threshold_type: Optional[str] = None, + auto_filters: Optional[Dict[str, Any]] = None, + ) -> None: + """Update an existing achievement.""" + fields = {} + if name is not None: + fields[Achievement.name] = name + if description is not None: + fields[Achievement.description] = description + if image_url is not None: + fields[Achievement.image_url] = image_url + if auto_award is not None: + fields[Achievement.auto_award] = auto_award + if auto_cadence is not None: + fields[Achievement.auto_cadence] = auto_cadence + if auto_threshold is not None: + fields[Achievement.auto_threshold] = auto_threshold + if auto_threshold_type is not None: + fields[Achievement.auto_threshold_type] = auto_threshold_type + if auto_filters is not None: + fields[Achievement.auto_filters] = auto_filters + + if fields: + DbManager.update_record(Achievement, achievement_id, fields) + + @staticmethod + def delete_achievement(achievement_id: int) -> None: + """Soft-delete an achievement by setting is_active = False.""" + DbManager.update_record(Achievement, achievement_id, {Achievement.is_active: False}) + + @staticmethod + def get_event_types(org_id: int) -> List[EventType]: + """Get all event types for the org.""" + return DbManager.find_records( + EventType, + filters=[ + EventType.is_active, + or_(EventType.specific_org_id == org_id, EventType.specific_org_id.is_(None)), + ], + ) + + @staticmethod + def get_event_tags(org_id: int) -> List[EventTag]: + """Get all event tags for the org.""" + return DbManager.find_records( + EventTag, + filters=[ + EventTag.is_active, + or_(EventTag.specific_org_id == org_id, EventTag.specific_org_id.is_(None)), + ], + ) + + @staticmethod + def tag_achievement( + user_id: int, + achievement_id: int, + date_awarded: datetime, + award_year: Optional[int] = -1, + award_period: Optional[int] = -1, + ) -> Achievement_x_User: + """Tag a user with an achievement.""" + return DbManager.create_record( + Achievement_x_User( + user_id=user_id, + date_awarded=date_awarded, + achievement_id=achievement_id, + award_year=award_year, + award_period=award_period, + ) + ) + + @staticmethod + def get_user_achievements_for_year(user_ids: List[int], year: int) -> List[Achievement_x_User]: + """Get all achievements for users in a given year.""" + return DbManager.find_records( + Achievement_x_User, + filters=[ + Achievement_x_User.user_id.in_(user_ids), + Achievement_x_User.date_awarded >= datetime(year, 1, 1), + Achievement_x_User.date_awarded <= datetime(year, 12, 31), + ], + ) + + +# ============================================================================= +# Views Class - Slack UI Construction +# ============================================================================= +class AchievementViews: + """Class for building Slack modal views related to achievements.""" + + @staticmethod + def build_config_modal( + region_record: SlackSettings, + achievements: List[Achievement], + ) -> SdkBlockView: + """Build the main achievement configuration modal.""" + # Build achievement list text + if achievements: + achievement_lines = [] + for a in achievements: + scope = "🌐 Global" if a.specific_org_id is None else "📍 Region" + mode = "Auto" if a.auto_award else "Manual" + description = f": _{a.description}_" if a.description else "" + achievement_lines.append(f"• *{a.name}* ({scope}, {mode}){description}") + achievement_list_text = "\n".join(achievement_lines) + else: + achievement_list_text = "_No achievements configured_" + + blocks = [ + InputBlock( + label=PlainTextObject(text="Enable Achievement Reporting"), + element=RadioButtonsElement( + options=as_selector_options( + names=["Enabled", "Disabled"], + values=["enabled", "disabled"], + ), + action_id=ACHIEVEMENT_CONFIG_ENABLE, + ), + block_id=ACHIEVEMENT_CONFIG_ENABLE, + optional=False, + ), + InputBlock( + label=PlainTextObject(text="Achievement Reporting Channel"), + element=ChannelSelectElement( + placeholder=PlainTextObject(text="Select a channel..."), + action_id=ACHIEVEMENT_CONFIG_CHANNEL, + ), + block_id=ACHIEVEMENT_CONFIG_CHANNEL, + optional=True, + hint=PlainTextObject(text="Channel where achievement announcements will be posted"), + ), + InputBlock( + label=PlainTextObject(text="How should achievements be posted?"), + element=RadioButtonsElement( + options=[ + Option( + text=PlainTextObject(text="Post each achievement individually"), + value="post_individually", + description=PlainTextObject( + text="Warning! This can generate a lot of notifications in the achievement channel" + ), + ), + Option( + text=PlainTextObject(text="Post a daily summary"), + value="post_summary", + description=PlainTextObject( + text="This will post a single summary of all the achievements earned each day" + ), + ), + Option( + text=PlainTextObject(text="Let PAX know individually"), + value="send_in_dms_only", + description=PlainTextObject(text="The achievement channel will not be used"), + ), + ], + action_id=ACHIEVEMENT_CONFIG_SEND_OPTION, + ), + block_id=ACHIEVEMENT_CONFIG_SEND_OPTION, + optional=False, + ), + DividerBlock(), + SectionBlock( + text=MarkdownTextObject(text="*Manage Region Achievements*"), + accessory=ButtonElement( + text=PlainTextObject(text="➕ New Achievement"), + action_id=ACHIEVEMENT_CONFIG_NEW_BTN, + ), + ), + SectionBlock( + text=MarkdownTextObject(text="_Edit or delete region-specific achievements_"), + accessory=ButtonElement( + text=PlainTextObject(text="✏️ Edit/Delete"), + action_id=ACHIEVEMENT_CONFIG_MANAGE_BTN, + ), + ), + DividerBlock(), + HeaderBlock(text=PlainTextObject(text="Active Achievements")), + SectionBlock( + text=MarkdownTextObject(text=achievement_list_text), + block_id=ACHIEVEMENT_CONFIG_ACHIEVEMENTS_LIST, + ), + ] + + form = SdkBlockView(blocks=blocks) + + # Set initial values + initial_values = { + ACHIEVEMENT_CONFIG_ENABLE: "enabled" if region_record.send_achievements else "disabled", + ACHIEVEMENT_CONFIG_SEND_OPTION: region_record.achievement_send_option or "post_summary", + } + if region_record.achievement_channel: + initial_values[ACHIEVEMENT_CONFIG_CHANNEL] = region_record.achievement_channel + + form.set_initial_values(initial_values) + + return form + + @staticmethod + def build_new_achievement_modal( + org_id: int, + event_types: List[EventType], + event_tags: List[EventTag], + edit_achievement: Optional[Achievement] = None, + ) -> SdkBlockView: + """Build the new/edit achievement modal.""" + # Period options + period_options = as_selector_options( + names=["Week", "Month", "Quarter", "Year", "Lifetime"], + values=["weekly", "monthly", "quarterly", "yearly", "lifetime"], + ) + + # Metric options + metric_options = as_selector_options( + names=["Posts (attendance count)", "Qs (times as Q)", "Unique AOs"], + values=["posts", "qs", "unique_aos"], + ) + + # Category options + category_options = as_selector_options( + names=["1st F (Fitness)", "2nd F (Fellowship)", "3rd F (Faith)"], + values=["first_f", "second_f", "third_f"], + ) + + # Event type options + event_type_options = as_selector_options( + names=[et.name for et in event_types] if event_types else ["No event types"], + values=[str(et.id) for et in event_types] if event_types else ["none"], + ) + + # Event tag options + event_tag_options = as_selector_options( + names=[et.name for et in event_tags] if event_tags else ["No event tags"], + values=[str(et.id) for et in event_tags] if event_tags else ["none"], + ) + + blocks = [ + InputBlock( + label=PlainTextObject(text="Achievement Name"), + element=PlainTextInputElement( + placeholder=PlainTextObject(text="Enter achievement name..."), + action_id=ACHIEVEMENT_NEW_NAME, + max_length=100, + ), + block_id=ACHIEVEMENT_NEW_NAME, + optional=False, + ), + InputBlock( + label=PlainTextObject(text="Description"), + element=PlainTextInputElement( + placeholder=PlainTextObject(text="Enter description..."), + action_id=ACHIEVEMENT_NEW_DESCRIPTION, + multiline=True, + max_length=500, + ), + block_id=ACHIEVEMENT_NEW_DESCRIPTION, + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Achievement Image URL"), + element=PlainTextInputElement( + placeholder=PlainTextObject(text="https://example.com/image.png"), + action_id=ACHIEVEMENT_NEW_IMAGE, + ), + block_id=ACHIEVEMENT_NEW_IMAGE, + optional=True, + hint=PlainTextObject(text="URL to an image for this achievement"), + ), + InputBlock( + label=PlainTextObject(text="Award Mode"), + element=RadioButtonsElement( + options=as_selector_options( + names=["Automatic (based on metrics)", "Manual (tagged by users)"], + values=["auto", "manual"], + ), + action_id=ACHIEVEMENT_NEW_AUTO_MANUAL, + ), + block_id=ACHIEVEMENT_NEW_AUTO_MANUAL, + optional=False, + ), + DividerBlock(), + HeaderBlock(text=PlainTextObject(text="Auto-Award Settings")), + ContextBlock( + elements=[PlainTextObject(text="Configure these settings if using automatic awards")], + ), + InputBlock( + label=PlainTextObject(text="Period"), + element=StaticSelectElement( + placeholder=PlainTextObject(text="Select period..."), + options=period_options, + action_id=ACHIEVEMENT_NEW_PERIOD, + ), + block_id=ACHIEVEMENT_NEW_PERIOD, + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Metric"), + element=StaticSelectElement( + placeholder=PlainTextObject(text="Select metric..."), + options=metric_options, + action_id=ACHIEVEMENT_NEW_METRIC, + ), + block_id=ACHIEVEMENT_NEW_METRIC, + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Threshold"), + element=NumberInputElement( + placeholder=PlainTextObject(text="Enter threshold..."), + is_decimal_allowed=False, + action_id=ACHIEVEMENT_NEW_THRESHOLD, + ), + block_id=ACHIEVEMENT_NEW_THRESHOLD, + optional=True, + hint=PlainTextObject(text="Minimum value to earn this achievement"), + ), + DividerBlock(), + HeaderBlock(text=PlainTextObject(text="Filter Settings (Include)")), + ContextBlock( + elements=[PlainTextObject(text="Only count events matching these criteria")], + ), + InputBlock( + label=PlainTextObject(text="Include Categories"), + element=CheckboxesElement( + options=category_options, + action_id=ACHIEVEMENT_NEW_FILTER_INCLUDE_CATEGORY, + ), + block_id=ACHIEVEMENT_NEW_FILTER_INCLUDE_CATEGORY, + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Include Event Types"), + element=StaticMultiSelectElement( + placeholder=PlainTextObject(text="Select event types..."), + options=event_type_options, + action_id=ACHIEVEMENT_NEW_FILTER_INCLUDE_EVENT_TYPE, + ), + block_id=ACHIEVEMENT_NEW_FILTER_INCLUDE_EVENT_TYPE, + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Include Event Tags"), + element=StaticMultiSelectElement( + placeholder=PlainTextObject(text="Select event tags..."), + options=event_tag_options, + action_id=ACHIEVEMENT_NEW_FILTER_INCLUDE_EVENT_TAG, + ), + block_id=ACHIEVEMENT_NEW_FILTER_INCLUDE_EVENT_TAG, + optional=True, + ), + DividerBlock(), + HeaderBlock(text=PlainTextObject(text="Filter Settings (Exclude)")), + ContextBlock( + elements=[PlainTextObject(text="Exclude events matching these criteria")], + ), + InputBlock( + label=PlainTextObject(text="Exclude Categories"), + element=CheckboxesElement( + options=category_options, + action_id=ACHIEVEMENT_NEW_FILTER_EXCLUDE_CATEGORY, + ), + block_id=ACHIEVEMENT_NEW_FILTER_EXCLUDE_CATEGORY, + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Exclude Event Types"), + element=StaticMultiSelectElement( + placeholder=PlainTextObject(text="Select event types..."), + options=event_type_options, + action_id=ACHIEVEMENT_NEW_FILTER_EXCLUDE_EVENT_TYPE, + ), + block_id=ACHIEVEMENT_NEW_FILTER_EXCLUDE_EVENT_TYPE, + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Exclude Event Tags"), + element=StaticMultiSelectElement( + placeholder=PlainTextObject(text="Select event tags..."), + options=event_tag_options, + action_id=ACHIEVEMENT_NEW_FILTER_EXCLUDE_EVENT_TAG, + ), + block_id=ACHIEVEMENT_NEW_FILTER_EXCLUDE_EVENT_TAG, + optional=True, + ), + ] + + form = SdkBlockView(blocks=blocks) + + # Pre-populate if editing + if edit_achievement: + initial_values = { + ACHIEVEMENT_NEW_NAME: edit_achievement.name, + ACHIEVEMENT_NEW_AUTO_MANUAL: "auto" if edit_achievement.auto_award else "manual", + } + if edit_achievement.description: + initial_values[ACHIEVEMENT_NEW_DESCRIPTION] = edit_achievement.description + if edit_achievement.image_url: + initial_values[ACHIEVEMENT_NEW_IMAGE] = edit_achievement.image_url + if edit_achievement.auto_cadence: + initial_values[ACHIEVEMENT_NEW_PERIOD] = edit_achievement.auto_cadence.name.lower() + if edit_achievement.auto_threshold_type: + initial_values[ACHIEVEMENT_NEW_METRIC] = edit_achievement.auto_threshold_type.lower() + if edit_achievement.auto_threshold: + initial_values[ACHIEVEMENT_NEW_THRESHOLD] = edit_achievement.auto_threshold + + # Parse filters + filters = edit_achievement.auto_filters or {} + includes = filters.get("include") or [] + excludes = filters.get("exclude") or [] + + for inc in includes: + if isinstance(inc, dict): + if "event_category" in inc: + initial_values[ACHIEVEMENT_NEW_FILTER_INCLUDE_CATEGORY] = inc["event_category"] + if "event_type_id" in inc: + initial_values[ACHIEVEMENT_NEW_FILTER_INCLUDE_EVENT_TYPE] = [ + str(x) for x in inc["event_type_id"] + ] + if "event_tag_id" in inc: + initial_values[ACHIEVEMENT_NEW_FILTER_INCLUDE_EVENT_TAG] = [str(x) for x in inc["event_tag_id"]] + + for exc in excludes: + if isinstance(exc, dict): + if "event_category" in exc: + initial_values[ACHIEVEMENT_NEW_FILTER_EXCLUDE_CATEGORY] = exc["event_category"] + if "event_type_id" in exc: + initial_values[ACHIEVEMENT_NEW_FILTER_EXCLUDE_EVENT_TYPE] = [ + str(x) for x in exc["event_type_id"] + ] + if "event_tag_id" in exc: + initial_values[ACHIEVEMENT_NEW_FILTER_EXCLUDE_EVENT_TAG] = [str(x) for x in exc["event_tag_id"]] + + form.set_initial_values(initial_values) + + return form + + @staticmethod + def build_manage_modal(achievements: List[Achievement]) -> SdkBlockView: + """Build the manage achievements modal with overflow menus.""" + blocks = [ + ContextBlock( + elements=[PlainTextObject(text="Only region-specific achievements can be edited or deleted.")], + ), + ] + + if not achievements: + blocks.append( + SectionBlock( + text=MarkdownTextObject(text="_No region-specific achievements found._"), + ), + ) + else: + for achievement in achievements: + mode = "🤖 Auto" if achievement.auto_award else "👤 Manual" + blocks.append( + SectionBlock( + text=MarkdownTextObject(text=f"*{achievement.name}*\n{mode}"), + block_id=f"{ACHIEVEMENT_MANAGE_OVERFLOW}_{achievement.id}", + accessory=OverflowMenuElement( + options=[ + Option(text="Edit Achievement", value=f"edit_{achievement.id}"), + Option(text="Delete Achievement", value=f"delete_{achievement.id}"), + ], + action_id=f"{ACHIEVEMENT_MANAGE_OVERFLOW}_{achievement.id}", + ), + ), + ) + + return SdkBlockView(blocks=blocks) + + @staticmethod + def build_tag_modal(achievements: List[Achievement]) -> SdkBlockView: + """Build the manual achievement tagging modal.""" + achievement_options = as_selector_options( + names=[a.name for a in achievements] if achievements else ["No achievements available"], + values=[str(a.id) for a in achievements] if achievements else ["none"], + descriptions=[a.description for a in achievements] if achievements else None, + ) + + blocks = [ + InputBlock( + label=PlainTextObject(text="Achievement"), + element=StaticSelectElement( + placeholder=PlainTextObject(text="Select the achievement..."), + options=achievement_options, + action_id=ACHIEVEMENT_TAG_SELECT, + ), + block_id=ACHIEVEMENT_TAG_SELECT, + optional=False, + hint=PlainTextObject( + text="If you don't see the achievement you're looking for, talk to your Weasel Shaker / Tech Q!" + ), + ), + InputBlock( + label=PlainTextObject(text="Select the PAX"), + element=StaticMultiSelectElement( + placeholder=PlainTextObject(text="Select the PAX..."), + action_id=ACHIEVEMENT_TAG_PAX, + ), + block_id=ACHIEVEMENT_TAG_PAX, + optional=False, + ), + InputBlock( + label=PlainTextObject(text="Achievement Date"), + element=StaticSelectElement( + placeholder=PlainTextObject(text="Select the date..."), + action_id=ACHIEVEMENT_TAG_DATE, + ), + block_id=ACHIEVEMENT_TAG_DATE, + optional=False, + hint=PlainTextObject( + text="Use a date in the period the achievement was earned, as some can be earned multiple times." + ), + ), + ] + + return SdkBlockView(blocks=blocks) + + +# ============================================================================= +# Handler Functions +# ============================================================================= +def build_config_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + """Build and display the achievement configuration modal.""" + trigger_id = safe_get(body, "trigger_id") + + if region_record.org_id not in ACHIEVEMENTS_ALPHA_TESTING_ORG_IDS: + form = SdkBlockView( + blocks=[ + SectionBlock( + text=MarkdownTextObject( + text=":construction: This feature is currently in alpha testing, coming soon to all regions! :construction:" # noqa E501 + ), + ), + ] + ) + submit = "None" + else: + service = AchievementService() + views = AchievementViews() + + achievements = service.get_all_achievements(region_record.org_id) + form = views.build_config_modal(region_record, achievements) + submit = "Submit" + + form.post_modal( + client=client, + trigger_id=trigger_id, + title_text="Achievement Settings", + callback_id=ACHIEVEMENT_CONFIG_CALLBACK_ID, + new_or_add="add", + submit_button_text=submit, + ) + + +def handle_config_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + """Handle the achievement configuration form submission.""" + form = AchievementViews.build_config_modal(region_record, []) + form_data = form.get_selected_values(body) + + # Update settings + region_record.send_achievements = 1 if form_data.get(ACHIEVEMENT_CONFIG_ENABLE) == "enabled" else 0 + region_record.achievement_channel = form_data.get(ACHIEVEMENT_CONFIG_CHANNEL) + region_record.achievement_send_option = form_data.get(ACHIEVEMENT_CONFIG_SEND_OPTION) or "post_summary" + + # Save to database + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + update_local_region_records() + + +def build_new_achievement_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + """Build and display the new achievement modal.""" + update_view_id = add_loading_form(body, client, new_or_add="add") + + service = AchievementService() + views = AchievementViews() + + event_types = service.get_event_types(region_record.org_id) + event_tags = service.get_event_tags(region_record.org_id) + + form = views.build_new_achievement_modal(region_record.org_id, event_types, event_tags) + + form.update_modal( + client=client, + view_id=update_view_id, + title_text="New Achievement", + callback_id=ACHIEVEMENT_NEW_CALLBACK_ID, + ) + + +def build_edit_achievement_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + achievement_id: int, +): + """Build and display the edit achievement modal.""" + update_view_id = add_loading_form(body, client, new_or_add="add") + + service = AchievementService() + views = AchievementViews() + + achievement = service.get_achievement(achievement_id) + event_types = service.get_event_types(region_record.org_id) + event_tags = service.get_event_tags(region_record.org_id) + + form = views.build_new_achievement_modal( + region_record.org_id, event_types, event_tags, edit_achievement=achievement + ) + + form.update_modal( + client=client, + view_id=update_view_id, + title_text="Edit Achievement", + callback_id=ACHIEVEMENT_NEW_CALLBACK_ID, + parent_metadata={"edit_achievement_id": achievement_id}, + ) + + +def handle_new_achievement_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + """Handle the new/edit achievement form submission.""" + service = AchievementService() + + # Create a dummy form to parse values + form = SdkBlockView(blocks=[]) + form_data = form.get_selected_values(body) + + # Extract form values + name = form_data.get(ACHIEVEMENT_NEW_NAME) + description = form_data.get(ACHIEVEMENT_NEW_DESCRIPTION) + image_url = form_data.get(ACHIEVEMENT_NEW_IMAGE) + auto_manual = form_data.get(ACHIEVEMENT_NEW_AUTO_MANUAL) + period = form_data.get(ACHIEVEMENT_NEW_PERIOD) + metric = form_data.get(ACHIEVEMENT_NEW_METRIC) + threshold = safe_convert(form_data.get(ACHIEVEMENT_NEW_THRESHOLD), int) + + # Build auto_filters + auto_filters = {"include": [], "exclude": []} + + # Include filters + include_categories = form_data.get(ACHIEVEMENT_NEW_FILTER_INCLUDE_CATEGORY) or [] + include_event_types = form_data.get(ACHIEVEMENT_NEW_FILTER_INCLUDE_EVENT_TYPE) or [] + include_event_tags = form_data.get(ACHIEVEMENT_NEW_FILTER_INCLUDE_EVENT_TAG) or [] + + if include_categories: + auto_filters["include"].append({"event_category": include_categories}) + if include_event_types and include_event_types != ["none"]: + auto_filters["include"].append({"event_type_id": [int(x) for x in include_event_types]}) + if include_event_tags and include_event_tags != ["none"]: + auto_filters["include"].append({"event_tag_id": [int(x) for x in include_event_tags]}) + + # Exclude filters + exclude_categories = form_data.get(ACHIEVEMENT_NEW_FILTER_EXCLUDE_CATEGORY) or [] + exclude_event_types = form_data.get(ACHIEVEMENT_NEW_FILTER_EXCLUDE_EVENT_TYPE) or [] + exclude_event_tags = form_data.get(ACHIEVEMENT_NEW_FILTER_EXCLUDE_EVENT_TAG) or [] + + if exclude_categories: + auto_filters["exclude"].append({"event_category": exclude_categories}) + if exclude_event_types and exclude_event_types != ["none"]: + auto_filters["exclude"].append({"event_type_id": [int(x) for x in exclude_event_types]}) + if exclude_event_tags and exclude_event_tags != ["none"]: + auto_filters["exclude"].append({"event_tag_id": [int(x) for x in exclude_event_tags]}) + + # Determine cadence + auto_cadence = None + if period: + cadence_map = { + "weekly": Achievement_Cadence.weekly, + "monthly": Achievement_Cadence.monthly, + "quarterly": Achievement_Cadence.quarterly, + "yearly": Achievement_Cadence.yearly, + "lifetime": Achievement_Cadence.lifetime, + } + auto_cadence = cadence_map.get(period) + + # Check if editing + metadata = json.loads(safe_get(body, "view", "private_metadata") or "{}") + edit_achievement_id = safe_convert(metadata.get("edit_achievement_id"), int) + + if edit_achievement_id: + # Update existing + service.update_achievement( + achievement_id=edit_achievement_id, + name=name, + description=description, + image_url=image_url, + auto_award=(auto_manual == "auto"), + auto_cadence=auto_cadence, + auto_threshold=threshold, + auto_threshold_type=metric, + auto_filters=auto_filters if auto_manual == "auto" else None, + ) + else: + # Create new + if name: + service.create_achievement( + name=name, + org_id=region_record.org_id, + description=description, + image_url=image_url, + auto_award=(auto_manual == "auto"), + auto_cadence=auto_cadence, + auto_threshold=threshold, + auto_threshold_type=metric, + auto_filters=auto_filters if auto_manual == "auto" else None, + ) + + +def build_manage_achievements_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + """Build and display the manage achievements modal.""" + service = AchievementService() + views = AchievementViews() + + achievements = service.get_region_achievements(region_record.org_id) + form = views.build_manage_modal(achievements) + + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Manage Achievements", + callback_id=ACHIEVEMENT_MANAGE_CALLBACK_ID, + submit_button_text="None", + new_or_add="add", + ) + + +def handle_manage_overflow(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + """Handle overflow menu selection in manage achievements modal.""" + selected_value = safe_get(body, "actions", 0, "selected_option", "value") + + if not selected_value: + return + + action_type, achievement_id_str = selected_value.split("_", 1) + achievement_id = safe_convert(achievement_id_str, int) + + if action_type == "edit": + build_edit_achievement_form(body, client, logger, context, region_record, achievement_id) + elif action_type == "delete": + service = AchievementService() + service.delete_achievement(achievement_id) + + +# ============================================================================= +# Tag Achievement Handlers (moved from weaselbot.py) +# ============================================================================= +def build_tag_achievement_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + """Build and display the tag achievement modal.""" + from utilities.slack import actions as legacy_actions + from utilities.slack import forms as legacy_forms + from utilities.slack import orm as legacy_orm + + if region_record.org_id not in ACHIEVEMENTS_ALPHA_TESTING_ORG_IDS: + form = SdkBlockView( + blocks=[ + SectionBlock( + text=MarkdownTextObject( + text=":construction: This feature is currently in alpha testing, coming soon to all regions! :construction:" # noqa E501 + ), + ), + ] + ) + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Tag Achievements", + callback_id=ACHIEVEMENT_TAG_CALLBACK_ID, + new_or_add="add", + submit_button_text="None", + ) + return + + update_view_id = safe_get(body, legacy_actions.LOADING_ID) + achievement_form = copy.deepcopy(legacy_forms.ACHIEVEMENT_FORM) + callback_id = ACHIEVEMENT_TAG_CALLBACK_ID + + # Build achievement list + service = AchievementService() + achievement_list = service.get_all_achievements(region_record.org_id) + + if achievement_list: + achievement_options = legacy_orm.as_selector_options( + names=[achievement.name for achievement in achievement_list], + values=[str(achievement.id) for achievement in achievement_list], + descriptions=[achievement.description for achievement in achievement_list], + ) + else: + achievement_options = legacy_orm.as_selector_options( + names=["No achievements available"], + values=["None"], + ) + + achievement_form.set_initial_values( + { + legacy_actions.ACHIEVEMENT_DATE: datetime.now(pytz.timezone("US/Central")).strftime("%Y-%m-%d"), + } + ) + achievement_form.set_options( + { + legacy_actions.ACHIEVEMENT_SELECT: achievement_options, + } + ) + + achievement_form.update_modal( + client=client, + view_id=update_view_id, + callback_id=callback_id, + title_text="Tag achievements", + ) + + +def handle_tag_achievement(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + """Handle the tag achievement form submission.""" + from utilities.slack import actions as legacy_actions + from utilities.slack import forms as legacy_forms + + achievement_data = legacy_forms.ACHIEVEMENT_FORM.get_selected_values(body) + achievement_pax_list = safe_get(achievement_data, legacy_actions.ACHIEVEMENT_PAX) + achievement_slack_user_list = [get_user(pax, region_record, client, logger) for pax in achievement_pax_list] + achievement_pax_list = [pax.user_id for pax in achievement_slack_user_list] + achievement_id = safe_convert(safe_get(achievement_data, legacy_actions.ACHIEVEMENT_SELECT), int) + achievement_date = datetime.strptime(safe_get(achievement_data, legacy_actions.ACHIEVEMENT_DATE), "%Y-%m-%d") + + service = AchievementService() + achievement_info = service.get_achievement(achievement_id) + achievement_name = achievement_info.name + + # Figure out which period this achievement falls into for the year (week, month, quarter, etc.) based on the achievement date # noqa + achievement_year = achievement_date.year + achievement_period = None + if achievement_info.auto_cadence == Achievement_Cadence.weekly: + achievement_period = achievement_date.isocalendar()[1] # Week number + elif achievement_info.auto_cadence == Achievement_Cadence.monthly: + achievement_period = achievement_date.month + elif achievement_info.auto_cadence == Achievement_Cadence.quarterly: + achievement_period = (achievement_date.month - 1) // 3 + 1 + elif achievement_info.auto_cadence == Achievement_Cadence.yearly: + achievement_period = 1 # All achievements in the same year fall into the same period + elif achievement_info.auto_cadence == Achievement_Cadence.lifetime: + achievement_period = 1 # Lifetime achievements don't have periods, but we can set it to 1 for consistency + + for pax in achievement_slack_user_list: + service.tag_achievement( + user_id=pax.user_id, + achievement_id=achievement_id, + date_awarded=current_date_cst(), + award_year=achievement_year, + award_period=achievement_period, + ) + + if region_record.send_achievements: + achievement_description = achievement_info.description + achievement_image_url = achievement_info.image_url + + if region_record.achievement_channel and region_record.achievement_send_option == "post_individually": + for pax in achievement_slack_user_list: + user_tag = f"<@{pax.slack_id}>" + msg = f"🏆 *{achievement_name}*" + if achievement_description: + msg += f"\n_{achievement_description}_" + msg += f"\n\nEarned by {user_tag}!" + if achievement_image_url: + msg += f"\n{achievement_image_url}" + client.chat_postMessage(channel=region_record.achievement_channel, text=msg) + elif region_record.achievement_channel and region_record.achievement_send_option == "post_summary": + earners = ", ".join(f"<@{pax.slack_id}>" for pax in achievement_slack_user_list) + desc = f": {achievement_description}" if achievement_description else "" + msg = f"🏆 *{achievement_name}*{desc}\nEarned by {earners}" + if achievement_image_url: + msg += f"\n{achievement_image_url}" + client.chat_postMessage(channel=region_record.achievement_channel, text=msg) + elif region_record.achievement_send_option == "send_in_dms_only": + for pax in achievement_slack_user_list: + msg = f"🎉 Congratulations! You've earned an achievement!\n\n🏆 *{achievement_name}*" + if achievement_description: + msg += f"\n_{achievement_description}_" + if achievement_image_url: + msg += f"\n{achievement_image_url}" + client.chat_postMessage(channel=pax.slack_id, text=msg) diff --git a/apps/slackbot/features/backblast.py b/apps/slackbot/features/backblast.py new file mode 100644 index 00000000..01d3cda0 --- /dev/null +++ b/apps/slackbot/features/backblast.py @@ -0,0 +1,1391 @@ +import copy +import json +import os +import ssl +import time +from collections import defaultdict +from datetime import datetime +from logging import Logger +from typing import Dict, List + +import pytz +from cryptography.fernet import Fernet +from f3_data_models.models import ( + Attendance, + Attendance_x_AttendanceType, + AttendanceType, + EventInstance, + EventType_x_EventInstance, + Location, + Org, + Org_Type, + Org_x_SlackSpace, + SlackSpace, + SlackUser, + User, +) +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient +from sqlalchemy import not_, or_ +from sqlmodel import func + +from features import connect +from utilities import constants, sendmail +from utilities.bot_logger import post_bot_log +from utilities.database.orm import SlackSettings +from utilities.database.special_queries import ( + MissingBackblastQuery, + event_attendance_query, + get_admin_users, + get_aoq_users, + missing_backblasts_query, +) +from utilities.helper_functions import ( + REGION_RECORDS, + current_date_cst, + fix_from_llm_tags, + get_location_display_name, + get_pax, + get_user, + parse_rich_block, + remove_keys_from_dict, + replace_rich_text_user_channel, + replace_user_channel_ids, + safe_convert, + safe_get, + upload_files_to_storage, +) +from utilities.slack import actions, forms +from utilities.slack import orm as slack_orm + +META_EXCLUDE_FROM_PAX_VAULT = "exclude_from_pax_vault" + + +def add_custom_field_blocks( + form: slack_orm.BlockView, region_record: SlackSettings, initial_values: dict = None +) -> slack_orm.BlockView: + if initial_values is None: + initial_values = {} + output_form = copy.deepcopy(form) + for custom_field in (region_record.custom_fields or {}).values(): + if safe_get(custom_field, "enabled"): + output_form.add_block( + slack_orm.InputBlock( + element=forms.CUSTOM_FIELD_TYPE_MAP[custom_field["type"]], + action=actions.CUSTOM_FIELD_PREFIX + custom_field["name"], + label=custom_field["name"], + optional=True, + ) + ) + if safe_get(custom_field, "type") == "Dropdown": + output_form.set_options( + { + actions.CUSTOM_FIELD_PREFIX + custom_field["name"]: slack_orm.as_selector_options( + names=custom_field["options"], + values=custom_field["options"], + ) + } + ) + if initial_values and custom_field["name"] in initial_values: + output_form.set_initial_values( + { + actions.CUSTOM_FIELD_PREFIX + custom_field["name"]: safe_convert( + safe_get(initial_values, custom_field["name"]), str + ) + or "" + } + ) + else: + output_form.set_initial_values({actions.CUSTOM_FIELD_PREFIX + custom_field["name"]: None}) + return output_form + + +def backblast_middleware( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + action_id = safe_get(body, "actions", 0, "action_id") or "" + if ( + region_record.org_id is None + or (safe_convert(region_record.migration_date, datetime.strptime, args=["%Y-%m-%d"]) or datetime.now()) + > datetime.now() + ): + connect.build_connect_options_form(body, client, logger, context, region_record) + elif action_id == actions.MSG_EVENT_BACKBLAST_BUTTON: + event_instance_id = safe_convert(safe_get(body, "actions", 0, "value"), int) + event_instance = DbManager.get(EventInstance, event_instance_id) + if event_instance.backblast_ts: + form = copy.deepcopy(forms.ALREADY_POSTED_FORM) + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Backblast", + callback_id=actions.ALREADY_POSTED, + submit_button_text="None", + ) + return + else: + build_backblast_form(body, client, logger, context, region_record) + elif ( + action_id == actions.PREBLAST_FILL_BACKBLAST_BUTTON + or action_id[: len(actions.BACKBLAST_FILL_BUTTON)] == actions.BACKBLAST_FILL_BUTTON + ): + event_instance_id = safe_convert(safe_get(body, "actions", 0, "value"), int) + event_instance = DbManager.get(EventInstance, event_instance_id) + if event_instance.backblast_ts: + form = copy.deepcopy(forms.ALREADY_POSTED_FORM) + form.update_modal( + client=client, + view_id=safe_get(body, actions.LOADING_ID), + title_text="Backblast", + callback_id=actions.ALREADY_POSTED, + submit_button_text="None", + ) + try: + blocks = safe_get(body, "message", "blocks") + blocks[-1].get("elements").pop(-1) # remove the fill backblast button + client.chat_update( + channel=safe_get(body, "container", "channel_id"), + ts=safe_get(body, "container", "message_ts"), + text=safe_get(body, "message", "text"), + blocks=blocks, + ) + except Exception as e: + logger.error(f"Error updating message to remove fill backblast button: {e}") + return + else: + user_id = get_user( + safe_get(body, "user", "id") or safe_get(body, "user_id"), region_record, client, logger + ).user_id + attendance_records = DbManager.find_records( + Attendance, + filters=[ + Attendance.event_instance_id == event_instance_id, + Attendance.user_id == user_id, + Attendance.attendance_x_attendance_types.any( + Attendance_x_AttendanceType.attendance_type_id.in_([2, 3]) + ), + Attendance.is_planned, + ], + joinedloads=[Attendance.attendance_x_attendance_types], + ) + admin_users = get_admin_users(region_record.org_id, region_record.team_id) + if attendance_records or any(u[0].id == user_id for u in admin_users): + build_backblast_form(body, client, logger, context, region_record, event_instance_id=event_instance_id) + else: + user = get_user(safe_get(body, "user", "id") or safe_get(body, "user_id"), region_record, client, logger) + user_id = user.user_id + event_records = event_attendance_query( + attendance_filter=[ + Attendance.user_id == user_id, + Attendance.is_planned, + Attendance.attendance_types.any(AttendanceType.id.in_([2, 3])), + ], + event_filter=[ + EventInstance.start_date <= current_date_cst(), + EventInstance.backblast_ts.is_(None), + EventInstance.is_active, + or_( + EventInstance.org_id == region_record.org_id, + EventInstance.org.has(Org.parent_id == region_record.org_id), + ), + or_( + EventInstance.meta.is_(None), + func.coalesce( + EventInstance.meta["backblast_reminder_dismissed"].as_boolean(), + False, + ).is_(False), + ), + ], + ) + + if event_records: + # sort by most recent date first + event_records.sort(key=lambda r: r.start_date, reverse=True) + select_blocks = [ + slack_orm.HeaderBlock(label=":point_up:Select From Recent Qs:"), + slack_orm.ActionsBlock( + elements=[ + slack_orm.ButtonElement( + label=f"{r.start_date.strftime('%m/%d')} {r.org.name} {' / '.join([t.name for t in r.event_types])}", # noqa: E501 + action=f"{actions.BACKBLAST_FILL_BUTTON}_{r.id}", + value=str(r.id), + ) + for r in event_records[:4] + ], + ), + ] + if len(event_records) > 4: + select_blocks.append( + slack_orm.InputBlock( + label="All past Qs", + action=actions.BACKBLAST_FILL_SELECT, + dispatch_action=True, + optional=False, + element=slack_orm.StaticSelectElement( + placeholder="Select an event", + options=slack_orm.as_selector_options( + names=[ + f"{r.start_date} {r.org.name} {' / '.join([t.name for t in r.event_types])}"[:50] + for r in event_records + ], + values=[str(r.id) for r in event_records], + ), + ), + hint="If not listed above", + ) + ) + else: + select_blocks = [ + slack_orm.SectionBlock(label="No past events for you to send a backblast for!"), + ] + + blocks = [ + *select_blocks, + slack_orm.DividerBlock(), + ] + blocks += [ + slack_orm.SectionBlock(label="Or, create a backblast for an event *not on the calendar:*"), + slack_orm.ActionsBlock( + elements=[ + slack_orm.ButtonElement( + label="New Unscheduled Event", + action=actions.BACKBLAST_NEW_BLANK_BUTTON, + confirm=slack_orm.ConfirmObject( + title="Are you sure?", + text="This option should ONLY BE USED FOR UNSCHEDULED EVENTS that are not listed on the calendar. If this is for a normal, scheduled event, please select it from the lists above.", # noqa + confirm="Yes, I'm sure", + deny="Whups, never mind", + style="danger", + ), + ), + ] + ), + ] + + admin_users = get_admin_users(region_record.org_id, region_record.team_id) + aoq_users = get_aoq_users(region_record.org_id) + user_is_admin = any(u[0].id == user_id for u in admin_users) or any(u.id == user_id for u in aoq_users) + if user_is_admin: + blocks.append(slack_orm.DividerBlock()) + blocks.append( + slack_orm.ActionsBlock( + elements=[ + slack_orm.ButtonElement( + label=":mag: Missing Backblasts", + action=actions.MISSING_BACKBLASTS_BUTTON, + value="missing", + ) + ] + ) + ) + + form = slack_orm.BlockView(blocks=blocks) + form.update_modal( + client=client, + view_id=safe_get(body, actions.LOADING_ID), + callback_id=actions.BACKBLAST_SELECT_CALLBACK_ID, + title_text="Select Backblast", + submit_button_text="None", + ) + + +def build_missing_backblasts_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + update_view_id: str = None, +): + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + user_id = get_user(slack_user_id, region_record, client, logger).user_id + + admin_users = get_admin_users(region_record.org_id, region_record.team_id) + aoq_users = get_aoq_users(region_record.org_id) + user_is_admin = any(u[0].id == user_id for u in admin_users) or any(u.id == user_id for u in aoq_users) + if not user_is_admin: + return + + from f3_data_models.models import Org_Type + + ao_records = DbManager.find_records( + Org, + filters=[Org.parent_id == region_record.org_id, Org.org_type == Org_Type.ao, Org.is_active.is_(True)], + ) + + # Build filter blocks first so we can parse existing selections + filter_block = slack_orm.InputBlock( + label="Filter by AO", + action=actions.MISSING_BACKBLASTS_AO_FILTER, + element=slack_orm.MultiStaticSelectElement( + placeholder="All AOs", + options=slack_orm.as_selector_options( + names=[ao.name for ao in ao_records], + values=[str(ao.id) for ao in ao_records], + ), + ), + dispatch_action=True, + optional=True, + ) + + existing_filter_data = {} + if safe_get(body, "view"): + existing_filter_data = slack_orm.BlockView(blocks=[filter_block]).get_selected_values(body) + + selected_ao_ids = [ + v + for v in ( + safe_convert(x, int) for x in (safe_get(existing_filter_data, actions.MISSING_BACKBLASTS_AO_FILTER) or []) + ) + if v is not None + ] + + missing: List[MissingBackblastQuery] = missing_backblasts_query( + region_org_id=region_record.org_id, + slack_team_id=region_record.team_id, + org_ids=selected_ao_ids or None, + ) + + blocks = [ + slack_orm.HeaderBlock(label=":mag: Missing Backblasts (Last 60 Days)"), + filter_block, + slack_orm.DividerBlock(), + ] + + if missing: + for item in missing: + event_type_str = " / ".join(t.name for t in item.event_types) + q_tag = " / ".join(f"<@{sid}>" for sid in item.q_slack_ids) if item.q_slack_ids else "Open" + site_q_tag = " / ".join(f"<@{sid}>" for sid in item.site_q_slack_ids) if item.site_q_slack_ids else "None" + label = ( + f"*{item.event.start_date.strftime('%m/%d')}* @ {item.org.name} {event_type_str}" + f" | Q: {q_tag} | Site Q: {site_q_tag}" + ) + + overflow_names = ["Edit Backblast", "Remove from List"] + if item.site_q_slack_ids: + overflow_names.append("Notify Site Q") + if item.q_slack_ids: + overflow_names.append("Notify Q") + + blocks.append( + slack_orm.SectionBlock( + label=label, + element=slack_orm.OverflowElement( + action=f"{actions.MISSING_BACKBLASTS_EVENT}_{item.event.id}", + options=slack_orm.as_selector_options(overflow_names), + ), + ) + ) + else: + blocks.append(slack_orm.SectionBlock(label="No missing backblasts in the last 60 days! :tada:")) + + form = slack_orm.BlockView(blocks=blocks) + form.set_initial_values(existing_filter_data) + view_id = update_view_id or safe_get(body, actions.LOADING_ID) or safe_get(body, "view", "id") + if view_id: + form.update_modal( + client=client, + view_id=view_id, + title_text="Missing Backblasts", + callback_id=actions.MISSING_BACKBLASTS_CALLBACK_ID, + submit_button_text="None", + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Missing Backblasts", + callback_id=actions.MISSING_BACKBLASTS_CALLBACK_ID, + new_or_add="add", + submit_button_text="None", + ) + + +def handle_missing_backblasts_overflow( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + action_id = safe_get(body, "actions", 0, "action_id") or "" + event_instance_id = safe_convert(action_id[len(actions.MISSING_BACKBLASTS_EVENT) + 1 :], int) + action = safe_get(body, "actions", 0, "selected_option", "value") + view_id = safe_get(body, "view", "id") + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + if action == "Edit Backblast": + build_backblast_form(body, client, logger, context, region_record, event_instance_id=event_instance_id) + return + + if action == "Remove from List": + event_record = DbManager.get(EventInstance, event_instance_id) + meta = (event_record.meta or {}).copy() + meta["backblast_admin_dismissed"] = True + DbManager.update_record(EventInstance, event_instance_id, fields={EventInstance.meta: meta}) + + elif action in ("Notify Q", "Notify Site Q"): + event_record = DbManager.get( + EventInstance, event_instance_id, joinedloads=[EventInstance.org, EventInstance.event_types] + ) + event_label = f"{event_record.org.name} on {event_record.start_date.strftime('%A, %B %d')}" + event_type_str = " / ".join(t.name for t in event_record.event_types) + + notify_blocks = [ + slack_orm.SectionBlock( + label=( + f"<@{slack_user_id}> is reminding you that the backblast for " + f"*{event_label}* ({event_type_str}) has not been submitted." + ) + ), + slack_orm.ActionsBlock( + elements=[ + slack_orm.ButtonElement( + label="Submit Backblast", + value=str(event_instance_id), + style="primary", + action=actions.MSG_EVENT_BACKBLAST_BUTTON, + ) + ] + ), + ] + notify_blocks_rendered = [b.as_form_field() for b in notify_blocks] + notify_text = f"Reminder: backblast for {event_label} has not been submitted." + + if action == "Notify Q": + # Pull planned Q/CoQ Slack IDs for this event + q_attendance = DbManager.find_records( + Attendance, + filters=[ + Attendance.event_instance_id == event_instance_id, + Attendance.is_planned, + Attendance.attendance_x_attendance_types.any( + Attendance_x_AttendanceType.attendance_type_id.in_([2, 3]) + ), + ], + joinedloads=[Attendance.slack_users, Attendance.attendance_x_attendance_types], + ) + target_slack_ids = [ + s.slack_id + for r in q_attendance + for s in (r.slack_users or []) + if s.slack_team_id == region_record.team_id + ] + else: + # Site Q — look up via Position + from utilities.database.special_queries import get_site_q_slack_ids_by_ao + + site_q_map = get_site_q_slack_ids_by_ao([event_record.org_id], region_record.team_id) + target_slack_ids = site_q_map.get(event_record.org_id, []) + + for target_id in target_slack_ids: + try: + client.chat_postMessage( + channel=target_id, + text=notify_text, + blocks=notify_blocks_rendered, + ) + except Exception as e: + logger.error(f"Error sending missing backblast notification to {target_id}: {e}") + + build_missing_backblasts_form(body, client, logger, context, region_record, update_view_id=view_id) + + +def build_backblast_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + event_instance_id: int = None, +): + """ + Args: + body (dict): Slack request body + client (WebClient): Slack WebClient object + logger (Logger): Logger object + context (dict): Slack request context + region_record (Region): Region record for the requesting region + """ + + user_id = safe_get(body, "user_id") or safe_get(body, "user", "id") + trigger_id = safe_get(body, "trigger_id") + backblast_metadata = safe_get(body, "message", "metadata", "event_payload") or {} + view_metadata = safe_convert(safe_get(body, "view", "private_metadata") or "{}", json.loads) + action_id = safe_get(body, "actions", 0, "action_id") or "" + f3_user_id = get_user(user_id, region_record, client, logger).user_id + is_scheduled = True + if event_instance_id: + pass + elif action_id == actions.BACKBLAST_FILL_SELECT: + event_instance_id = safe_convert(safe_get(body, "actions", 0, "selected_option", "value"), int) + elif action_id == actions.MSG_EVENT_BACKBLAST_BUTTON: + event_instance_id = safe_convert(safe_get(body, "actions", 0, "value"), int) + elif action_id[: len(actions.BACKBLAST_FILL_BUTTON)] == actions.BACKBLAST_FILL_BUTTON: + event_instance_id = safe_convert(safe_get(body, "actions", 0, "value"), int) + elif action_id == actions.BACKBLAST_NEW_BLANK_BUTTON or view_metadata.get("is_unscheduled") == "true": + event_instance_id = None + is_scheduled = False + event_record = None + elif action_id == actions.BACKBLAST_NOQ_SELECT: + event_instance_id = safe_convert(safe_get(body, "actions", 0, "selected_option", "value"), int) + try: + DbManager.create_record( + Attendance( + event_instance_id=event_instance_id, + user_id=f3_user_id, + is_planned=True, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=2)], # assign as Q + ) + ) + except Exception as e: + logger.error(f"Error creating attendance record for backblast no-Q select: {e}") + else: + event_instance_id = safe_get(backblast_metadata, "event_instance_id") + update_view_id = safe_get(body, actions.LOADING_ID) or safe_get(body, "view", "id") + + backblast_form = copy.deepcopy(forms.BACKBLAST_FORM) + attendance_non_slack_users = [] + is_paxminer_backblast = False + if event_instance_id: + event_record: EventInstance = DbManager.get( + EventInstance, event_instance_id, joinedloads=[EventInstance.org, EventInstance.event_types] + ) + event_metadata = event_record.meta or {} + already_posted = event_record.backblast_ts is not None + already_saved = event_record.pax_count is not None + attendance_records: List[Attendance] = DbManager.find_records( + Attendance, + [Attendance.event_instance_id == event_instance_id, Attendance.is_planned != already_saved], + joinedloads="all", + ) + attendance_slack_dict = { + r: next((s.slack_id for s in (r.slack_users or []) if s.slack_team_id == region_record.team_id), None) + for r in attendance_records + } + # create a list of attendance records that are not in slack + attendance_non_slack_users = [r for r in attendance_records if not attendance_slack_dict[r]] + q_list = [ + attendance_slack_dict[r] + for r in attendance_records + if bool({t.id for t in r.attendance_types}.intersection([2])) and attendance_slack_dict[r] + ] + if not q_list and safe_get(body, "actions", 0, "action_id") == actions.BACKBLAST_NOQ_SELECT: + q_list = [user_id] + coq_list = [ + attendance_slack_dict[r] + for r in attendance_records + if bool({t.id for t in r.attendance_types}.intersection([3])) and attendance_slack_dict[r] + ] + slack_pax_list = [attendance_slack_dict[r] for r in attendance_records if attendance_slack_dict[r]] + + if already_posted or event_record.backblast_rich or event_record.backblast: + if event_record.backblast_rich and len(event_record.backblast_rich) > 0: + moleskin_block = [ + block for block in event_record.backblast_rich if safe_get(block, "type") == "rich_text" + ][0] + elif event_record.backblast: + # this will happen when trying to edit a backblast from the old paxminer system + is_paxminer_backblast = True + moleskin_block = { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": event_record.backblast, + } + ], + } + ], + } + # find moleskin block number and add hint to edit form about legacy backblast + for i, block in enumerate(backblast_form.blocks): + if block.action == actions.BACKBLAST_MOLESKIN: + backblast_form.blocks[ + i + ].hint = ":warning: This backblast was created in paxminer. If resaving, we recommend deleting the header lines so they are not duplicated." # noqa: E501 + else: + moleskin_block = None + initial_backblast_data = { + actions.BACKBLAST_TITLE: event_record.name, + actions.BACKBLAST_INFO: f""" +*AO:* {event_record.org.name} +*DATE:* {event_record.start_date.strftime("%Y-%m-%d")} +*EVENT TYPE:* {" / ".join([t.name for t in event_record.event_types])} +""", + # actions.BACKBLAST_DATE: event_record.start_date.strftime("%Y-%m-%d"), + # actions.BACKBLAST_AO: event_record.org.meta["slack_channel_id"], + actions.BACKBLAST_Q: safe_get(q_list, 0), + actions.BACKBLAST_COQ: coq_list, + actions.BACKBLAST_PAX: slack_pax_list, + actions.BACKBLAST_MOLESKIN: moleskin_block or region_record.backblast_moleskin_template, + actions.BACKBLAST_FNGS: safe_get(backblast_metadata, actions.BACKBLAST_FNGS) or "", + actions.BACKBLAST_NONSLACK_PAX: safe_get(backblast_metadata, actions.BACKBLAST_NONSLACK_PAX) or "", + # actions.BACKBLAST_EVENT_TYPE: str(event_record.event_types[0].id), # picking the first for now + # TODO: non-slack pax + } + # Restore options checkbox state + options = [] + if safe_get(event_metadata, META_EXCLUDE_FROM_PAX_VAULT): + options.append("exclude_from_pax_vault") + if options: + initial_backblast_data[actions.BACKBLAST_OPTIONS] = options + backblast_metadata["event_instance_id"] = event_instance_id + elif safe_get(backblast_metadata, actions.BACKBLAST_TITLE): + initial_backblast_data = backblast_metadata + moleskin_block = safe_get(body, "message", "blocks", 1) + moleskin_block = remove_keys_from_dict(moleskin_block, ["display_team_id", "display_url"]) + initial_backblast_data[actions.BACKBLAST_MOLESKIN] = moleskin_block + event_metadata = {} + else: + # this is triggered for unscheduled backblasts + for block in forms.UNSCHEDULED_BACKBLAST_BLOCKS: + backblast_form.blocks.insert(2, block) + backblast_form.delete_block(actions.BACKBLAST_INFO) + aos: List[Org] = DbManager.find_records( + Org, [Org.parent_id == region_record.org_id, Org.is_active, Org.org_type == Org_Type.ao] + ) + ao_location_records = DbManager.find_join_records2( + Location, + Org, + [ + Location.org_id == Org.id, + or_(Org.parent_id == region_record.org_id, Org.id == region_record.org_id), + Location.is_active, + ], + ) + location_records: List[Location] = [record[0] for record in ao_location_records] + # De-duplicate in case a location appears in both sources. + location_records = list({location.id: location for location in location_records}.values()) + location_records.sort(key=lambda location: (get_location_display_name(location) or "").lower()) + region_org_record: Org = DbManager.get(Org, region_record.org_id, joinedloads=[Org.event_types]) + backblast_form.set_options( + { + actions.BACKBLAST_AO: slack_orm.as_selector_options( + names=[ao.name for ao in aos], + values=[str(ao.id) for ao in aos], + ), + actions.BACKBLAST_EVENT_TYPE: slack_orm.as_selector_options( + names=[event_type.name for event_type in region_org_record.event_types], + values=[str(event_type.id) for event_type in region_org_record.event_types], + ), + actions.BACKBLAST_LOCATION: slack_orm.as_selector_options( + names=[get_location_display_name(location) for location in location_records], + values=[str(location.id) for location in location_records], + ), + } + ) + initial_backblast_data = { + actions.BACKBLAST_Q: user_id, + actions.BACKBLAST_DATE: datetime.now(pytz.timezone("US/Central")).strftime("%Y-%m-%d"), + actions.BACKBLAST_MOLESKIN: region_record.backblast_moleskin_template, + } + event_metadata = {} + attendance_non_slack_users = [] + + org_event_types: Org = DbManager.get(Org, region_record.org_id, joinedloads=[Org.event_types]) + event_type_options = slack_orm.as_selector_options( + [r.name for r in org_event_types.event_types], [str(r.id) for r in org_event_types.event_types] + ) + + if (current_date_cst() < (safe_get(event_record, "start_date") or current_date_cst())) or is_paxminer_backblast: + initial_backblast_data[actions.BACKBLAST_SEND_OPTIONS] = "Save and send later" + + backblast_form.set_options({actions.BACKBLAST_EVENT_TYPE: event_type_options}) + backblast_form.set_initial_values(initial_backblast_data) + backblast_form = add_custom_field_blocks(backblast_form, region_record, initial_values=event_metadata) + + if safe_get(backblast_metadata, actions.BACKBLAST_FILE, 0): + backblast_form.blocks.insert( + 1, + slack_orm.ImageBlock( + image_url=backblast_metadata[actions.BACKBLAST_FILE][0], + alt_text="Existing Boyband", + ), + ) + + if attendance_non_slack_users: + update_list = [ + { + "text": r.user.f3_name, + "value": str(r.user.id), + } + for r in attendance_non_slack_users + ] + backblast_form.set_initial_values({actions.USER_OPTION_LOAD: update_list}) + + if (region_record.email_enabled or 0) == 0 or (region_record.email_option_show or 0) == 0: + backblast_form.delete_block(actions.BACKBLAST_EMAIL_SEND) + # backblast_metadata = None + if action_id == actions.BACKBLAST_EDIT_BUTTON or safe_get(event_record, "backblast_ts"): + callback_id = actions.BACKBLAST_EDIT_CALLBACK_ID + backblast_metadata["channel_id"] = safe_get(body, "container", "channel_id") + backblast_metadata["message_ts"] = safe_get(body, "container", "message_ts") + backblast_metadata["files"] = safe_get(backblast_metadata, actions.BACKBLAST_FILE) or [] + backblast_metadata["file_ids"] = safe_get(backblast_metadata, "file_ids") or [] + else: + callback_id = actions.BACKBLAST_CALLBACK_ID + + backblast_metadata["is_scheduled"] = is_scheduled + + if update_view_id: + backblast_form.update_modal( + client=client, + view_id=update_view_id, + callback_id=callback_id, + title_text="Backblast", + parent_metadata=backblast_metadata, + ) + else: + backblast_form.post_modal( + client=client, + trigger_id=trigger_id, + callback_id=callback_id, + title_text="Backblast", + parent_metadata=backblast_metadata, + ) + + +def handle_backblast_post(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + create_or_edit = "create" if safe_get(body, "view", "callback_id") == actions.BACKBLAST_CALLBACK_ID else "edit" + metadata = json.loads(safe_get(body, "view", "private_metadata") or "{}") + event_instance_id = safe_get(metadata, "event_instance_id") + backblast_form = copy.deepcopy(forms.BACKBLAST_FORM) + backblast_form = add_custom_field_blocks(backblast_form, region_record) + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + if event_instance_id: + event: EventInstance = DbManager.get( + EventInstance, event_instance_id, joinedloads=[EventInstance.org, EventInstance.event_types] + ) + date = event.start_date + event_type = event.event_types[0].id if event.event_types else None + event_org = event.org + backblast_data: dict = backblast_form.get_selected_values(body) + print(f"Backblast data from scheduled event: {backblast_data}") + else: + event = None + date = None + event_type = None + for block in forms.UNSCHEDULED_BACKBLAST_BLOCKS: + backblast_form.blocks.append(block) + backblast_data: dict = backblast_form.get_selected_values(body) + event_org = DbManager.get(Org, safe_convert(safe_get(backblast_data, actions.BACKBLAST_AO), int)) + + logger.debug(f"Backblast data: {backblast_data}") + + title = safe_get(backblast_data, actions.BACKBLAST_TITLE) + the_date = safe_get(backblast_data, actions.BACKBLAST_DATE) or date + the_q = safe_get(backblast_data, actions.BACKBLAST_Q) + the_coq = safe_get(backblast_data, actions.BACKBLAST_COQ) + pax = safe_get(backblast_data, actions.BACKBLAST_PAX) + dr_pax = [int(id) for id in (safe_get(backblast_data, actions.USER_OPTION_LOAD) or [])] + non_slack_pax = safe_get(backblast_data, actions.BACKBLAST_NONSLACK_PAX) + fngs = safe_get(backblast_data, actions.BACKBLAST_FNGS) + count = safe_get(backblast_data, actions.BACKBLAST_COUNT) + moleskin = fix_from_llm_tags(safe_get(backblast_data, actions.BACKBLAST_MOLESKIN)) + email_send = safe_get(backblast_data, actions.BACKBLAST_EMAIL_SEND) + send_options = safe_get(backblast_data, actions.BACKBLAST_SEND_OPTIONS) + # ao = safe_get(backblast_data, actions.BACKBLAST_AO) + event_type = safe_convert(safe_get(backblast_data, actions.BACKBLAST_EVENT_TYPE), int) or event_type + location_id = safe_convert(safe_get(backblast_data, actions.BACKBLAST_LOCATION), int) or safe_get( + event, "location_id" + ) + files = safe_get(backblast_data, actions.BACKBLAST_FILE) or [] + file_ids = safe_get(backblast_data, "file_ids") or [] + selected_options = safe_get(backblast_data, actions.BACKBLAST_OPTIONS) or [] + + user_id = safe_get(body, "user_id") or safe_get(body, "user", "id") + if files: + file_list, file_send_list, file_ids, low_rez_file_list = upload_files_to_storage( + files=files, + logger=logger, + client=client, + bucket_name="event_instance_images", + file_name=str(event_instance_id) if event_instance_id else None, + ) + elif safe_get(metadata, actions.BACKBLAST_FILE, 0): + file_list = safe_get(metadata, actions.BACKBLAST_FILE) + file_send_list = [] + file_ids = safe_get(metadata, "file_ids") + low_rez_file_list = safe_get(metadata, actions.BACKBLAST_FILE + "_low_rez") + else: + file_list = [] + file_send_list = [] + file_ids = [] + low_rez_file_list = [] + + if ( + region_record.default_backblast_destination == constants.CONFIG_DESTINATION_SPECIFIED["value"] + and region_record.backblast_destination_channel + ): + destination_channel = region_record.backblast_destination_channel + else: + destination_channel = safe_get(event_org.meta, "slack_channel_id") + + if create_or_edit == "edit": + message_channel = safe_get(metadata, "channel_id") + message_ts = safe_get(metadata, "message_ts") + file_list = safe_get(metadata, "files") if not file_list else file_list + file_ids = safe_get(metadata, "file_ids") if not file_ids else file_ids + else: + message_channel = None + message_ts = None + + all_pax = list(set([the_q] + (the_coq or []) + pax)) + db_users: List[SlackUser] = [get_user(p, region_record, client, logger) for p in all_pax] + db_ids = [] + db_users_deduped = [] + for u in db_users: + if u.user_id not in db_ids: + db_users_deduped.append(u) + db_ids.append(u.user_id) + db_users = db_users_deduped + auto_count = len(all_pax) + pax_names_list = [user.user_name for user in db_users] + + pax_formatted = get_pax(pax) + pax_full_list = [pax_formatted] + fngs_formatted = fngs + fng_count = 0 + if dr_pax: + dr_pax_users = DbManager.find_records( + User, + [User.id.in_(dr_pax)], + joinedloads=[User.home_region_org], + ) + dr_pax_names = [] + for u in dr_pax_users: + if u.id not in db_ids: + db_users.append(SlackUser(user_id=u.id, slack_id=u.id, user_name=u.f3_name)) + db_ids.append(u.id) + if u.home_region_org: + dr_pax_names.append(f"{u.f3_name} ({u.home_region_org.name})") + else: + dr_pax_names.append(u.f3_name) + dr_pax_names = [u.f3_name for u in dr_pax_users] + dr_pax_names = ", ".join(dr_pax_names) + pax_full_list.append(dr_pax_names) + pax_names_list.append(dr_pax_names) + auto_count += len(dr_pax_names.split(",")) + if non_slack_pax: + pax_full_list.append(non_slack_pax) + pax_names_list.append(non_slack_pax) + auto_count += non_slack_pax.count(",") + 1 + if fngs: + pax_full_list.append(fngs) + pax_names_list.append(fngs) + fng_count = fngs.count(",") + 1 + fngs_formatted = str(fng_count) + " " + fngs + auto_count += fngs.count(",") + 1 + pax_formatted = ", ".join(pax_full_list) + pax_names = ", ".join(pax_names_list) + + if the_coq is None: + the_coqs_formatted = "" + the_coqs_names = "" + else: + the_coqs_formatted = get_pax(the_coq) + the_coqs_full_list = [the_coqs_formatted] + the_coqs_users = [get_user(c, region_record, client, logger) for c in the_coq] + the_coqs_names_list = [user.user_name for user in the_coqs_users] + the_coqs_formatted = ", " + ", ".join(the_coqs_full_list) + the_coqs_names = ", " + ", ".join(the_coqs_names_list) + + # ao_name = get_channel_names([the_ao], logger, client)[0] + q_user = get_user(the_q, region_record, client, logger) + q_name = q_user.user_name + q_url = q_user.avatar_url + count = count or auto_count + + post_msg = f"""*Backblast! {title}* +*DATE*: {the_date} +*AO*: {event_org.name} +*Q*: <@{the_q}>{the_coqs_formatted} +*PAX*: {pax_formatted} +*FNGs*: {fngs_formatted} +*COUNT*: {count}""" + + custom_fields = {} + for field, value in backblast_data.items(): + if (field[: len(actions.CUSTOM_FIELD_PREFIX)] == actions.CUSTOM_FIELD_PREFIX) and value: + post_msg += f"\n*{field[len(actions.CUSTOM_FIELD_PREFIX) :]}*: {str(value)}" + custom_fields[field[len(actions.CUSTOM_FIELD_PREFIX) :]] = value + + if file_list: + custom_fields["files"] = file_list + custom_fields["file_ids"] = file_ids + + if selected_options: + for option in selected_options: + custom_fields[option] = True + + msg_block = slack_orm.SectionBlock(label=post_msg) + + backblast_data.pop(actions.BACKBLAST_MOLESKIN, None) + backblast_data.pop(actions.BACKBLAST_PAX, None) + backblast_data[actions.BACKBLAST_FILE] = file_list + backblast_data[actions.BACKBLAST_FILE + "_low_rez"] = low_rez_file_list + backblast_data["file_ids"] = file_ids + backblast_data[actions.BACKBLAST_OP] = user_id + backblast_data["event_instance_id"] = event_instance_id + + if not event_instance_id: + event_instance = DbManager.create_record( + EventInstance(start_date=the_date, org_id=event_org.id, name=title, location_id=location_id) + ) + event_instance_id = event_instance.id + DbManager.create_record( + Attendance( + event_instance_id=event_instance_id, + user_id=q_user.user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=2)], # assign as Q + is_planned=True, + ) + ) + DbManager.create_record( + EventType_x_EventInstance(event_instance_id=event_instance_id, event_type_id=event_type) + ) + backblast_data["event_instance_id"] = event_instance_id + + edit_block = slack_orm.ActionsBlock( + elements=[ + slack_orm.ButtonElement( + label=":pencil: Edit this backblast", + action=actions.BACKBLAST_EDIT_BUTTON, + value=json.dumps(backblast_data), + ), + slack_orm.ButtonElement( + label=":heavy_plus_sign: New backblast", + action=actions.BACKBLAST_NEW_BUTTON, + value="new", + ), + ] + ) + + if region_record.strava_enabled: + edit_block.elements.append( + slack_orm.ButtonElement( + label=":runner: Connect to Strava", + action=actions.BACKBLAST_STRAVA_BUTTON, + value="strava", + ) + ) + + blocks = [msg_block.as_form_field(), moleskin] + for url in low_rez_file_list or []: + blocks.append( + slack_orm.ImageBlock( + alt_text=title, + image_url=url, + ).as_form_field() + ) + blocks.append(edit_block.as_form_field()) + if "exclude_from_pax_vault" in selected_options: + blocks.append( + slack_orm.ContextBlock( + element=slack_orm.ContextElement( + initial_value="*Note: stats from this event will not be reflected in the PAX Vault.*" + ) + ).as_form_field() + ) + + moleskin_text = parse_rich_block(moleskin) + moleskin_text_w_names = replace_user_channel_ids( + moleskin_text, region_record, client, logger + ) # check this for efficiency + moleskin_w_names = replace_rich_text_user_channel(moleskin, region_record, client, logger) + + # Handle "Save and send later" option - save to DB but don't post to Slack + if create_or_edit == "create" and send_options == "Save and send later": + backblast_parsed = f"""Backblast! {title} +Date: {the_date} +AO: {event_org.name} +Q: {q_name} {the_coqs_names} +PAX: {pax_names} +FNGs: {fngs_formatted} +COUNT: {count} +{moleskin_text_w_names} +""" + db_fields = { + EventInstance.start_date: the_date, + EventInstance.org_id: event_org.id, + EventInstance.backblast_ts: None, # Not posted yet + EventInstance.backblast: backblast_parsed, + EventInstance.backblast_rich: [msg_block.as_form_field(), moleskin], + EventInstance.name: title, + EventInstance.pax_count: count, + EventInstance.fng_count: fng_count, + EventInstance.meta: custom_fields, + EventInstance.is_active: True, + } + DbManager.update_record(EventInstance, event_instance_id, fields=db_fields) + DbManager.update_records( + EventType_x_EventInstance, + [EventType_x_EventInstance.event_instance_id == event_instance_id], + fields={EventType_x_EventInstance.event_type_id: event_type}, + ) + + attendance_types = [2 if u.slack_id == the_q else 3 if u.slack_id in (the_coq or []) else 1 for u in db_users] + attendance_records = [ + Attendance( + event_instance_id=event_instance_id, + user_id=user.user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=attendance_type)], + is_planned=False, + ) + for user, attendance_type in zip(db_users, attendance_types, strict=False) + ] + DbManager.create_records(attendance_records) + + # Notify user that backblast was saved but not posted + client.chat_postMessage( + channel=user_id, + text=f"Your backblast for *{title}* has been saved but not posted yet. " + f"To post it later, use the /backblast command and select the event from your recent Qs.", + ) + return + + if create_or_edit == "create": + text = (f"{moleskin_text_w_names}\n\nUse the 'New Backblast' button to create a new backblast")[:1500] + if destination_channel: + res = client.chat_postMessage( + channel=destination_channel, + text=text, + username=f"{q_name} (via F3 Nation)", + icon_url=q_url, + blocks=blocks, + metadata={"event_type": "backblast", "event_payload": backblast_data}, + ) + action_text = "posted" + else: + res = client.chat_postMessage( + channel=user_id, + text="Your backblast has been saved to the database but a Slack channel has not been configured for your AO, so it cannot be posted to Slack. The Slack channel can be set by an admin in `/f3-nation-settings` -> Calendar Settings -> Manage AOs.", # noqa: E501 + ) + action_text = "saved (no channel)" + if (email_send and email_send == "yes") or (email_send is None and region_record.email_enabled == 1): + moleskin_msg = moleskin_text_w_names + + if region_record.postie_format: + subject = f"[{event_org.name}] {title}" + moleskin_msg += f"\n\nTags: {event_org.name}, {pax_names}" + else: + subject = title + + email_msg = f"""Date: {the_date} +AO: {event_org.name} +Q: {q_name} {the_coqs_names} +PAX: {pax_names} +FNGs: {fngs_formatted} +COUNT: {count} +{moleskin_msg} + """ + try: + # Decrypt password + fernet = Fernet(os.environ[constants.PASSWORD_ENCRYPT_KEY].encode()) + email_password_decrypted = fernet.decrypt(region_record.email_password.encode()).decode() + sendmail.send( + subject=subject, + body=email_msg, + email_server=region_record.email_server, + email_server_port=region_record.email_server_port, + email_user=region_record.email_user, + email_password=email_password_decrypted, + email_to=region_record.email_to, + attachments=file_send_list, + ) + logger.debug("\nEmail Sent! \n{}".format(email_msg)) + except Exception as sendmail_err: + logger.error("Error with sendmail: {}".format(sendmail_err)) + logger.debug("\nEmail Sent! \n{}".format(email_msg)) + + elif create_or_edit == "edit": + text = (f"{moleskin_text_w_names}\n\nUse the 'New Backblast' button to create a new backblast")[:1500] + try: + res = client.chat_update( + channel=message_channel, + ts=message_ts, + text=text, + username=f"{q_name} (via F3 Nation)", + icon_url=q_url, + blocks=blocks, + metadata={"event_type": "backblast", "event_payload": backblast_data}, + ) + logger.debug("\nBackblast updated in Slack! \n{}".format(post_msg)) + except Exception as e: + logger.warning(f"Error updating backblast message in Slack, posting a new one: {e}") + res = client.chat_postMessage( + channel=destination_channel, + text=text, + username=f"{q_name} (via F3 Nation)", + icon_url=q_url, + blocks=blocks, + metadata={"event_type": "backblast", "event_payload": backblast_data}, + ) + action_text = "edited" + log_msg = f":page_facing_up: Backblast {action_text} for *{title}* on *{the_date}* by <@{slack_user_id or 'app'}>" + if res and res.get("channel") and res.get("ts"): + log_msg += f" \n" + post_bot_log( + client=client, + region_record=region_record, + text=log_msg, # noqa: E501 + logger=logger, + ) + + # res_link = client.chat_getPermalink(channel=chan or message_channel, message_ts=res["ts"]) + + backblast_parsed = f"""Backblast! {title} +Date: {the_date} +AO: {event_org.name} +Q: {q_name} {the_coqs_names} +PAX: {pax_names} +FNGs: {fngs_formatted} +COUNT: {count} +{moleskin_text_w_names} +""" + rich_blocks: list = res["message"]["blocks"] + rich_blocks.pop(-1) + + db_fields = { + EventInstance.start_date: the_date, + EventInstance.org_id: event_org.id, + EventInstance.backblast_ts: res["ts"], + EventInstance.backblast: backblast_parsed, + EventInstance.backblast_rich: res["message"]["blocks"], + EventInstance.name: title, + EventInstance.pax_count: count, + EventInstance.fng_count: fng_count, + EventInstance.meta: custom_fields, + EventInstance.location_id: location_id, + EventInstance.is_active: True, + } + event: EventInstance = DbManager.get(EventInstance, event_instance_id, joinedloads="all") + DbManager.update_record(EventInstance, event_instance_id, fields=db_fields) + DbManager.update_records( + EventType_x_EventInstance, + [EventType_x_EventInstance.event_instance_id == event_instance_id], + fields={EventType_x_EventInstance.event_type_id: event_type}, + ) # TODO: handle multiple event types + + attendance_types = [2 if u.slack_id == the_q else 3 if u.slack_id in (the_coq or []) else 1 for u in db_users] + attendance_records = [ + Attendance( + event_instance_id=event_instance_id, + user_id=user.user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=attendance_type)], + is_planned=False, + ) + for user, attendance_type in zip(db_users, attendance_types, strict=False) + ] + + DbManager.delete_records( + Attendance, + filters=[Attendance.event_instance_id == event_instance_id, not_(Attendance.is_planned)], + ) + DbManager.create_records(attendance_records) + + # ── Downrange cross-posting ──────────────────────────────────────────────── + # Find PAX who have a home region different from the current region and cross-post + # backblasts to those regions if they have cross-posting enabled. + cross_post_msg = f""":airplane: *Downrange! {title}* +*DATE*: {the_date} +*REGION*: {region_record.workspace_name or event_org.name} +*AO*: {event_org.name} +*Q*: {q_name}{the_coqs_names} +*PAX*: {pax_names} +*FNGs*: {fngs_formatted} +*COUNT*: {count}""" + for field, value in custom_fields.items(): + if field not in ("files", "file_ids", "downrange_posts") and not field.endswith("_low_rez") and value: + cross_post_msg += f"\n*{field}*: {str(value)}" + + all_user_ids = [u.user_id for u in db_users if u.user_id] + if all_user_ids: + pax_user_records = DbManager.find_records( + User, + [User.id.in_(all_user_ids), User.home_region_id.isnot(None)], + ) + foreign_region_ids = { + u.home_region_id for u in pax_user_records if u.home_region_id and u.home_region_id != region_record.org_id + } + foreign_user_names: Dict[int, List[str]] = defaultdict(list) # region_id -> list of f3_names + for u in pax_user_records: + if u.home_region_id and u.home_region_id != region_record.org_id: + foreign_user_names[u.home_region_id].append(u.f3_name) + + existing_dr_posts = safe_get(event.meta if event else {}, "downrange_posts") or [] + existing_by_team = {p["team_id"]: p for p in existing_dr_posts} + new_dr_posts = list(existing_by_team.values()) # preserve existing; updated in-place below + + ssl_ctx = ssl.create_default_context() + ssl_ctx.check_hostname = False + ssl_ctx.verify_mode = ssl.CERT_NONE + + for home_region_id in foreign_region_ids: + ox = DbManager.find_first_record(Org_x_SlackSpace, [Org_x_SlackSpace.org_id == home_region_id]) + if not ox: + continue + dr_slack_space = DbManager.get(SlackSpace, ox.slack_space_id) + if not dr_slack_space or not dr_slack_space.bot_token: + continue + + dr_settings = REGION_RECORDS.get(dr_slack_space.team_id) + if not dr_settings and dr_slack_space.settings: + try: + dr_settings = SlackSettings(**dr_slack_space.settings) + except Exception: + continue + + if not dr_settings: + continue + if dr_settings.downrange_channel_posting != "enabled" or not dr_settings.downrange_channel: + continue + + cross_blocks = [ + slack_orm.SectionBlock(label=cross_post_msg).as_form_field(), + moleskin_w_names, + ] + + for url in low_rez_file_list or []: + cross_blocks.append( + slack_orm.ImageBlock( + alt_text=title, + image_url=url, + ).as_form_field() + ) + + cross_blocks.append( + slack_orm.ContextBlock( + element=slack_orm.ContextElement( + initial_value=f"Cross-posted from *{event_org.name}* for PAX: {', '.join(foreign_user_names.get(home_region_id) or [])}" # noqa: E501 + ) + ).as_form_field() + ) + + try: + dr_client = WebClient(token=dr_slack_space.bot_token, ssl=ssl_ctx) + existing_post = existing_by_team.get(dr_slack_space.team_id) + + if create_or_edit == "edit" and existing_post: + dr_client.chat_update( + channel=existing_post["channel"], + ts=existing_post["ts"], + text=cross_post_msg[:1500], + username=f"{q_name} (via F3 Nation)", + icon_url=q_url, + blocks=cross_blocks, + ) + else: + dr_res = dr_client.chat_postMessage( + channel=dr_settings.downrange_channel, + text=cross_post_msg[:1500], + username=f"{q_name} (via F3 Nation)", + icon_url=q_url, + blocks=cross_blocks, + ) + new_post_entry = { + "team_id": dr_slack_space.team_id, + "channel": dr_settings.downrange_channel, + "ts": dr_res["ts"], + } + idx = next((i for i, p in enumerate(new_dr_posts) if p["team_id"] == dr_slack_space.team_id), None) + if idx is not None: + new_dr_posts[idx] = new_post_entry + else: + new_dr_posts.append(new_post_entry) + except Exception as e: + logger.warning(f"Downrange cross-post failed for team {dr_slack_space.team_id}: {e}") + time.sleep(1) + + if new_dr_posts: + updated_meta = dict(custom_fields) + updated_meta["downrange_posts"] = new_dr_posts + DbManager.update_record(EventInstance, event_instance_id, fields={EventInstance.meta: updated_meta}) + + +def handle_backblast_edit_button( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + user_id = safe_get(body, "user_id") or safe_get(body, "user", "id") + # channel_id = safe_get(body, "channel_id") or safe_get(body, "channel", "id") + + if constants.ALL_USERS_ARE_ADMINS: + user_is_admin = True + else: + slack_user = get_user(user_id, region_record, client, logger) + admin_users = get_admin_users(region_record.org_id, region_record.team_id) + user_is_admin = any(u[0].id == slack_user.user_id for u in admin_users) + + backblast_data = safe_get(body, "message", "metadata", "event_payload") or json.loads( + safe_get(body, "actions", 0, "value") or "{}" + ) + if region_record.editing_locked == 1: + allow_edit: bool = ( + user_is_admin + or (user_id == backblast_data[actions.BACKBLAST_Q]) + or (user_id in (safe_get(backblast_data, actions.BACKBLAST_COQ) or [])) + or (user_id in backblast_data[actions.BACKBLAST_OP]) + ) + else: + allow_edit = True + + if allow_edit: + build_backblast_form( + body=body, + client=client, + logger=logger, + context=context, + region_record=region_record, + ) + else: + form = slack_orm.BlockView( + blocks=[ + slack_orm.SectionBlock( + label="Editing this backblast is only allowed for the Q(s), the original poster, or your local region admins.", # noqa + ) + ] + ) + form.update_modal( + client=client, + view_id=safe_get(body, actions.LOADING_ID), + callback_id=actions.BACKBLAST_EDIT_CALLBACK_ID, + title_text="Backblast Edit", + submit_button_text="None", + ) + + +def handle_legacy_edit_button( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + form = slack_orm.BlockView( + blocks=[ + slack_orm.SectionBlock( + label="This backblast was created before migration to F3 Nation. To edit the backblast or attendance details, have an admin go to the calendar, use the start date filter to go back, and hit 'Edit Backblast' on the appropriate event.", # noqa + ), + slack_orm.ActionsBlock( + elements=[ + slack_orm.ButtonElement( + label="Open Calendar", + action=actions.OPEN_CALENDAR_BUTTON, + ), + ] + ), + ] + ) + form.update_modal( + client=client, + view_id=safe_get(body, actions.LOADING_ID), + callback_id=actions.BACKBLAST_EDIT_CALLBACK_ID, + title_text="Legacy Backblast", + submit_button_text="None", + ) diff --git a/apps/slackbot/features/calendar/__init__.py b/apps/slackbot/features/calendar/__init__.py new file mode 100644 index 00000000..cda1565d --- /dev/null +++ b/apps/slackbot/features/calendar/__init__.py @@ -0,0 +1,76 @@ +from typing import List + +from utilities.slack import actions, orm + +PREBLAST_MESSAGE_ACTION_ELEMENTS = [ + orm.ButtonElement(label=":hc: HC/Un-HC", action=actions.EVENT_PREBLAST_HC_UN_HC), + orm.ButtonElement(label=":pencil: Edit Preblast", action=actions.EVENT_PREBLAST_EDIT, value="Edit Preblast"), +] + + +def get_preblast_action_buttons(has_q: bool = True, event_instance_id: int = None) -> List[orm.ButtonElement]: + buttons = [ + orm.ButtonElement(label=":hc: HC/Un-HC", action=actions.EVENT_PREBLAST_HC_UN_HC), + orm.ButtonElement(label=":pencil: Edit Preblast", action=actions.EVENT_PREBLAST_EDIT, value="Edit Preblast"), + orm.ButtonElement(label=":heavy_plus_sign: New Preblast", action=actions.NEW_PREBLAST_BUTTON), + ] + if not has_q: + buttons.append( + orm.ButtonElement( + label=":raising_hand: Take Q", action=actions.EVENT_PREBLAST_TAKE_Q, value=str(event_instance_id) + ) + ) + if event_instance_id: + buttons.append( + orm.ButtonElement( + label=":back: Fill Backblast", + action=actions.PREBLAST_FILL_BACKBLAST_BUTTON, + value=str(event_instance_id), + ) + ) + return buttons + + +def get_preblast_action_blocks(has_q: bool = True, event_instance_id: int = None) -> List[orm.BaseBlock]: + overflow_labels = [ + ":pencil: Edit Preblast", + ":heavy_plus_sign: New Preblast", + # orm.ButtonElement(label=":pencil: Edit Preblast", action=actions.EVENT_PREBLAST_EDIT, value="Edit Preblast"), + # orm.ButtonElement(label=":heavy_plus_sign: New Preblast", action=actions.NEW_PREBLAST_BUTTON), + ] + overflow_values = [ + f"{actions.EVENT_PREBLAST_EDIT}_{event_instance_id}", + actions.NEW_PREBLAST_BUTTON, + ] + if event_instance_id: + overflow_labels.append(":back: Fill Backblast") + overflow_values.append(f"{actions.PREBLAST_FILL_BACKBLAST_BUTTON}_{event_instance_id}") + blocks = [ + orm.ActionsBlock( + action="hc-un-hc-actions", + elements=[ + orm.ButtonElement(label=":hc: HC/Un-HC", action=actions.EVENT_PREBLAST_HC_UN_HC), + orm.OverflowElement( + action=actions.PREBLAST_OVERFLOW_ACTION, + options=orm.as_selector_options(names=overflow_labels, values=overflow_values), + ), + ], + ), + # orm.ButtonElement(label=":pencil: Edit Preblast", action=actions.EVENT_PREBLAST_EDIT, value="Edit Preblast"), + # orm.ButtonElement(label=":heavy_plus_sign: New Preblast", action=actions.NEW_PREBLAST_BUTTON), + ] + if not has_q: + blocks.insert( + 0, + orm.ActionsBlock( + action="take-q-action", + elements=[ + orm.ButtonElement( + label=":raising_hand: Take Q", + action=actions.EVENT_PREBLAST_TAKE_Q, + value=str(event_instance_id), + ) + ], + ), + ) + return blocks diff --git a/apps/slackbot/features/calendar/ao.py b/apps/slackbot/features/calendar/ao.py new file mode 100644 index 00000000..b4f496fe --- /dev/null +++ b/apps/slackbot/features/calendar/ao.py @@ -0,0 +1,361 @@ +import copy +import json +from logging import Logger + +import requests +from slack_sdk.models.blocks import ImageBlock, InputBlock, SectionBlock +from slack_sdk.models.blocks.basic_components import ConfirmObject, PlainTextObject +from slack_sdk.models.blocks.block_elements import ( + ChannelSelectElement, + FileInputElement, + PlainTextInputElement, + StaticSelectElement, +) +from slack_sdk.web import WebClient + +from application.ao import AoData +from application.ao.service import AoService +from application.location import LocationData +from application.location.service import LocationService +from infrastructure.api_client import get_api_ao_repository, get_api_location_repository +from utilities.bot_logger import post_bot_log +from utilities.builders import add_loading_form +from utilities.database.orm import SlackSettings +from utilities.helper_functions import ( + MapUpdateData, + get_location_display_name, + safe_convert, + safe_get, + sort_by_name, + trigger_map_revalidation, + upload_files_to_storage, +) +from utilities.slack import actions +from utilities.slack.sdk_orm import SdkBlockView, as_selector_options + +# --------------------------------------------------------------------------- +# Composition root +# --------------------------------------------------------------------------- + + +def _build_ao_service() -> AoService: + """Build the AO service using the production API-backed repository.""" + return AoService(repository=get_api_ao_repository()) + + +def _build_location_service() -> LocationService: + """Build the location service using the production API-backed repository.""" + return LocationService(repository=get_api_location_repository()) + + +# --------------------------------------------------------------------------- +# Views +# --------------------------------------------------------------------------- + + +class AoViews: + """Pure Slack UI construction for AOs — no I/O.""" + + @staticmethod + def build_add_ao_modal(locations: list[LocationData]) -> SdkBlockView: + """Return the add-AO form with dynamic location options populated.""" + form = copy.deepcopy(AO_FORM) + location_options = as_selector_options( + names=[get_location_display_name(loc) for loc in locations], + values=[str(loc.id) for loc in locations], + ) + if location_block := form.get_block(actions.CALENDAR_ADD_AO_LOCATION): + location_block.element.options = location_options + return form + + @staticmethod + def build_edit_ao_modal(ao: AoData, locations: list[LocationData]) -> SdkBlockView: + """Return the add-AO form pre-filled with *ao*'s existing data.""" + form = AoViews.build_add_ao_modal(locations) + + slack_id = safe_get(ao.meta, "slack_channel_id") if ao.meta else None + initial_values: dict = {actions.CALENDAR_ADD_AO_NAME: ao.name} + if ao.description: + initial_values[actions.CALENDAR_ADD_AO_DESCRIPTION] = ao.description + if slack_id: + initial_values[actions.CALENDAR_ADD_AO_CHANNEL] = slack_id + form.set_initial_values(initial_values) + + if ao.default_location_id: + form.set_initial_values({actions.CALENDAR_ADD_AO_LOCATION: str(ao.default_location_id)}) + + return form + + @staticmethod + def build_ao_list_modal(aos: list[AoData]) -> SdkBlockView: + """Return the list modal showing all AOs with edit/delete controls.""" + if not aos: + return SdkBlockView( + blocks=[SectionBlock(text="No AOs found. Please add an AO first.", block_id="ao-notice")] + ) + blocks = [ + SectionBlock( + text=a.name, + block_id=f"{actions.AO_EDIT_DELETE}_{a.id}", + accessory=StaticSelectElement( + placeholder="Edit or Delete", + options=as_selector_options(names=["Edit", "Delete"]), + confirm=ConfirmObject( + title="Are you sure?", + text=( + "Are you sure you want to edit / delete this AO? " + "This cannot be undone. Deleting an AO will also " + "delete all associated series and events." + ), + confirm="Yes, I'm sure", + deny="Whups, never mind", + ), + action_id=f"{actions.AO_EDIT_DELETE}_{a.id}", + ), + ) + for a in aos + ] + return SdkBlockView(blocks=blocks) + + +# --------------------------------------------------------------------------- +# Handler functions +# --------------------------------------------------------------------------- + + +def manage_aos(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + action = safe_get(body, "actions", 0, "selected_option", "value") + + if action == "add": + update_view_id = add_loading_form(body, client, new_or_add="add") + locations = _build_location_service().get_org_locations(region_record.org_id) + locations.sort(key=sort_by_name(lambda x: x.name)) + form = AoViews.build_add_ao_modal(locations) + form.update_modal( + client=client, + view_id=update_view_id, + title_text="Add an AO", + callback_id=actions.ADD_AO_CALLBACK_ID, + ) + elif action == "edit": + ao_service = _build_ao_service() + aos = ao_service.get_region_aos(region_record.org_id) + aos.sort(key=sort_by_name(lambda x: x.name)) + form = AoViews.build_ao_list_modal(aos) + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Edit or Delete an AO", + callback_id=actions.EDIT_DELETE_AO_CALLBACK_ID, + submit_button_text="None", + new_or_add="add", + ) + + +def handle_ao_add(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + form_data = AO_FORM.get_selected_values(body) + metadata = safe_convert(safe_get(body, "view", "private_metadata"), json.loads) or {} + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + name = safe_get(form_data, actions.CALENDAR_ADD_AO_NAME) + description = safe_get(form_data, actions.CALENDAR_ADD_AO_DESCRIPTION) + slack_channel_id = safe_get(form_data, actions.CALENDAR_ADD_AO_CHANNEL) + default_location_id = safe_get(form_data, actions.CALENDAR_ADD_AO_LOCATION) + parent_id = region_record.org_id + + ao_service = _build_ao_service() + + if safe_get(metadata, "ao_id"): + ao_id = metadata["ao_id"] + ao_service.update_ao( + ao_id=ao_id, + parent_id=parent_id, + name=name, + description=description, + slack_channel_id=slack_channel_id, + default_location_id=default_location_id, + ) + map_action = "map.updated" + org_id = ao_id + action_text = "edited" + else: + ao_data = ao_service.create_ao( + parent_id=parent_id, + name=name, + description=description, + slack_channel_id=slack_channel_id, + default_location_id=default_location_id, + ) + map_action = "map.created" + org_id = ao_data.id + action_text = "created" + + file = safe_get(form_data, actions.CALENDAR_ADD_AO_LOGO, 0) + if file: + file_list, _, _, _ = upload_files_to_storage( + files=[file], + logger=logger, + client=client, + enforce_square=True, + max_height=512, + bucket_name="org-logos", + file_name=str(org_id), + enforce_png=True, + ) + logo_url = safe_get(file_list, 0) + if logo_url: + ao_service.update_ao( + ao_id=org_id, + parent_id=parent_id, + name=name, + description=description, + slack_channel_id=slack_channel_id, + default_location_id=default_location_id, + logo_url=logo_url, + ) + + trigger_map_revalidation(action=map_action, map_update_data=MapUpdateData(orgId=org_id)) + + action_text = ( + f":pencil2: AO edited: {name} by <@{slack_user_id or 'app'}>" + if safe_get(metadata, "ao_id") + else f":heavy_plus_sign: AO {action_text}: {name} by <@{slack_user_id or 'app'}>" + ) + post_bot_log(client=client, region_record=region_record, text=action_text, logger=logger) + + +def build_ao_add_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + edit_ao: AoData = None, + update_view_id: str = None, + loading_form: bool = False, +): + """Build and open/update the add-AO modal. + + Called by ``handle_ao_edit_delete`` when editing an existing AO. + The ``loading_form`` path pushes a loading placeholder first, then + replaces it with the real form. + """ + if loading_form: + update_view_id = add_loading_form(body, client, new_or_add="add") + + locations = _build_location_service().get_org_locations(region_record.org_id) + locations.sort(key=sort_by_name(lambda x: x.name)) + + if edit_ao: + form = AoViews.build_edit_ao_modal(edit_ao, locations) + if edit_ao.logo_url: + try: + if requests.head(edit_ao.logo_url).status_code == 200: + form.blocks.insert(5, ImageBlock(image_url=edit_ao.logo_url, alt_text="AO Logo")) + except requests.RequestException as e: + logger.error(f"Error fetching AO logo: {e}") + title_text = "Edit AO" + parent_metadata = {"ao_id": edit_ao.id} + else: + form = AoViews.build_add_ao_modal(locations) + title_text = "Add an AO" + parent_metadata = {} + + if update_view_id: + form.update_modal( + client=client, + view_id=update_view_id, + title_text=title_text, + callback_id=actions.ADD_AO_CALLBACK_ID, + parent_metadata=parent_metadata, + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text=title_text, + callback_id=actions.ADD_AO_CALLBACK_ID, + new_or_add="add", + parent_metadata=parent_metadata, + ) + + +def handle_ao_edit_delete(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + action_id: str = safe_get(body, "actions", 0, "action_id") or "" + ao_id = safe_convert(action_id.split("_")[1] if "_" in action_id else None, int) + action = safe_get(body, "actions", 0, "selected_option", "value") + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + ao_service = _build_ao_service() + + if action == "Edit" and ao_id is not None: + ao = ao_service.get_ao_by_id(ao_id) + if ao: + build_ao_add_form(body, client, logger, context, region_record, edit_ao=ao, loading_form=True) + elif action == "Delete" and ao_id is not None: + ao = ao_service.get_ao_by_id(ao_id) + ao_service.delete_ao(ao_id) + trigger_map_revalidation(action="map.deleted", map_update_data=MapUpdateData(orgId=ao_id)) + post_bot_log( + client=client, + region_record=region_record, + text=f":wastebasket: AO deleted: {ao.name if ao else ao_id} by <@{slack_user_id}>", + logger=logger, + ) + + +# --------------------------------------------------------------------------- +# Form template (module-level, deepcopied before use) +# --------------------------------------------------------------------------- + +AO_FORM = SdkBlockView( + blocks=[ + InputBlock( + label=PlainTextObject(text="AO Title"), + block_id=actions.CALENDAR_ADD_AO_NAME, + element=PlainTextInputElement( + action_id=actions.CALENDAR_ADD_AO_NAME, + placeholder=PlainTextObject(text="Enter the AO name"), + ), + optional=False, + ), + InputBlock( + label=PlainTextObject(text="Description"), + block_id=actions.CALENDAR_ADD_AO_DESCRIPTION, + element=PlainTextInputElement( + action_id=actions.CALENDAR_ADD_AO_DESCRIPTION, + placeholder=PlainTextObject(text="Enter a description for the AO"), + multiline=True, + ), + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Channel associated with this AO:"), + block_id=actions.CALENDAR_ADD_AO_CHANNEL, + element=ChannelSelectElement( + action_id=actions.CALENDAR_ADD_AO_CHANNEL, + placeholder=PlainTextObject(text="Select a channel"), + ), + optional=False, + ), + InputBlock( + label=PlainTextObject(text="Default Location"), + block_id=actions.CALENDAR_ADD_AO_LOCATION, + element=StaticSelectElement( + action_id=actions.CALENDAR_ADD_AO_LOCATION, + placeholder=PlainTextObject(text="Select a location"), + ), + optional=True, + ), + InputBlock( + label=PlainTextObject(text="AO Logo"), + block_id=actions.CALENDAR_ADD_AO_LOGO, + element=FileInputElement( + action_id=actions.CALENDAR_ADD_AO_LOGO, + max_files=1, + filetypes=["png", "jpg", "heic", "bmp"], + ), + optional=True, + ), + ] +) diff --git a/apps/slackbot/features/calendar/config.py b/apps/slackbot/features/calendar/config.py new file mode 100644 index 00000000..39eca50b --- /dev/null +++ b/apps/slackbot/features/calendar/config.py @@ -0,0 +1,265 @@ +import copy +from logging import Logger + +from f3_data_models.models import SlackSpace +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient + +from features.calendar import event_instance, event_tag, event_type +from utilities.constants import EVENT_TAG_COLORS +from utilities.database.orm import SlackSettings +from utilities.helper_functions import safe_convert, safe_get +from utilities.slack import actions, orm + +CALENDAR_CONFIG_POST_CALENDAR_IMAGE = "calendar_config_post_calendar_image" +CALENDAR_CONFIG_CALENDAR_IMAGE_CHANNEL = "calendar_config_calendar_image_channel" +CALENDAR_CONFIG_Q_LINEUP_METHOD = "calendar_config_q_lineup_method" +CALENDAR_CONFIG_Q_LINEUP_CHANNEL = "calendar_config_q_lineup_channel" +CALENDAR_CONFIG_Q_LINEUP_DAY = "calendar_config_q_lineup_day" +CALENDAR_CONFIG_Q_LINEUP_TIME = "calendar_config_q_lineup_time" +CALENDAR_CONFIG_GROUP_BY_OPTION = "calendar_config_group_by_option" +CALENDAR_CONFIG_OPEN_EVENT_COLOR = "calendar_config_open_event_color" + + +def build_calendar_config_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + form = copy.deepcopy(CALENDAR_CONFIG_FORM) + form.update_modal( # TODO: add a "back to main menu" button? + client=client, + view_id=safe_get(body, "view", "id"), + title_text="Calendar Settings", + callback_id=actions.CALENDAR_CONFIG_CALLBACK_ID, + submit_button_text="None", + ) + + +def build_calendar_general_config_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + form = copy.deepcopy(CALENDAR_CONFIG_GENERAL_FORM) + q_lineups_time = ( + f"{str(region_record.send_q_lineups_hour_cst).zfill(2)}:00" + if region_record.send_q_lineups_hour_cst + else "17:00" + ) + form.set_initial_values( + { + CALENDAR_CONFIG_GROUP_BY_OPTION: region_record.calendar_group_by_option or "ao", + CALENDAR_CONFIG_OPEN_EVENT_COLOR: region_record.open_event_color or "Green", + actions.CALENDAR_CONFIG_Q_LINEUP: "yes" if region_record.send_q_lineups else "no", + CALENDAR_CONFIG_Q_LINEUP_METHOD: region_record.send_q_lineups_method or "yes_per_ao", + CALENDAR_CONFIG_Q_LINEUP_CHANNEL: region_record.send_q_lineups_channel, + CALENDAR_CONFIG_POST_CALENDAR_IMAGE: "yes" if region_record.q_image_posting_enabled else "no", + CALENDAR_CONFIG_CALENDAR_IMAGE_CHANNEL: region_record.q_image_posting_channel, + CALENDAR_CONFIG_Q_LINEUP_DAY: safe_convert(region_record.send_q_lineups_day, str) or "6", + CALENDAR_CONFIG_Q_LINEUP_TIME: q_lineups_time, + } + ) + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Calendar Settings", + callback_id=actions.CALENDAR_CONFIG_GENERAL_CALLBACK_ID, + new_or_add="add", + ) + + +def handle_calendar_config_general( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + form = copy.deepcopy(CALENDAR_CONFIG_GENERAL_FORM) + values = form.get_selected_values(body) + region_record.calendar_group_by_option = safe_get(values, CALENDAR_CONFIG_GROUP_BY_OPTION) + region_record.send_q_lineups = safe_get(values, actions.CALENDAR_CONFIG_Q_LINEUP) == "yes" + region_record.send_q_lineups_method = safe_get(values, CALENDAR_CONFIG_Q_LINEUP_METHOD) + region_record.send_q_lineups_channel = safe_get(values, CALENDAR_CONFIG_Q_LINEUP_CHANNEL) + region_record.q_image_posting_enabled = safe_get(values, CALENDAR_CONFIG_POST_CALENDAR_IMAGE) == "yes" + region_record.q_image_posting_channel = safe_get(values, CALENDAR_CONFIG_CALENDAR_IMAGE_CHANNEL) + region_record.open_event_color = safe_get(values, CALENDAR_CONFIG_OPEN_EVENT_COLOR) + region_record.send_q_lineups_day = safe_convert(safe_get(values, CALENDAR_CONFIG_Q_LINEUP_DAY), int) + send_q_lineups_time = safe_convert(safe_get(values, CALENDAR_CONFIG_Q_LINEUP_TIME), str) + region_record.send_q_lineups_hour_cst = ( + safe_convert(send_q_lineups_time.split(":")[0], int) if send_q_lineups_time else 17 + ) + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + + +CALENDAR_CONFIG_GENERAL_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Send Q Lineups", + action=actions.CALENDAR_CONFIG_Q_LINEUP, + element=orm.RadioButtonsElement( + options=orm.as_selector_options( + names=["Yes", "No"], + values=["yes", "no"], + ), + initial_value="yes", + ), + optional=False, + ), + orm.InputBlock( + label="How should they be sent?", + action=CALENDAR_CONFIG_Q_LINEUP_METHOD, + element=orm.RadioButtonsElement( + options=orm.as_selector_options( + names=["One Per AO", "One For All AOs"], + values=["yes_per_ao", "yes_for_all"], + ), + initial_value="yes", + ), + optional=True, + hint="This setting only applies if 'Send Q Lineups' is set to 'Yes'.", + ), + orm.InputBlock( + label="Region Q Lineup Channel", + action=CALENDAR_CONFIG_Q_LINEUP_CHANNEL, + element=orm.ChannelsSelectElement(placeholder="Select a channel"), + optional=True, + hint="This setting only applies if 'Send Q Lineups' is set to 'Yes' and 'How should they be sent?' is set to 'One For All AOs'.", # noqa + ), + orm.InputBlock( + label="Region Q Lineup Day", + action=CALENDAR_CONFIG_Q_LINEUP_DAY, + element=orm.StaticSelectElement( + options=orm.as_selector_options( + names=["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], + values=["0", "1", "2", "3", "4", "5", "6"], + ), + initial_value="6", + ), + optional=False, + ), + orm.InputBlock( + label="Region Q Lineup Time (CST)", + action=CALENDAR_CONFIG_Q_LINEUP_TIME, + element=orm.TimepickerElement(initial_value="17:00"), + optional=False, + hint="These settings only applies if 'Send Q Lineups' is set to 'Yes'.", + ), + orm.DividerBlock(), + orm.InputBlock( + label="Post Calendar Image", + action=CALENDAR_CONFIG_POST_CALENDAR_IMAGE, + element=orm.RadioButtonsElement( + options=orm.as_selector_options( + names=["Yes", "No"], + values=["yes", "no"], + ), + initial_value="no", + ), + optional=False, + ), + orm.InputBlock( + label="Calendar Image Channel", + action=CALENDAR_CONFIG_CALENDAR_IMAGE_CHANNEL, + element=orm.ChannelsSelectElement(placeholder="Select a channel"), + optional=True, + ), + orm.InputBlock( + label="Group Calendar By Option", + action=CALENDAR_CONFIG_GROUP_BY_OPTION, + element=orm.RadioButtonsElement( + options=orm.as_selector_options( + names=["AO", "Location"], + values=["ao", "location"], + ), + initial_value="ao", + ), + optional=False, + hint="This setting controls how events are grouped on calendar images and on `/f3-calendar`.", + ), + orm.InputBlock( + label="Open Event Color", + action=CALENDAR_CONFIG_OPEN_EVENT_COLOR, + element=orm.StaticSelectElement( + options=orm.as_selector_options( + names=[c for c in EVENT_TAG_COLORS.keys() if c != "Closed"], + values=[c for c in EVENT_TAG_COLORS.keys() if c != "Closed"], + ), + initial_value="Green", + ), + optional=False, + hint="Color used for open events on the calendar image.", + ), + ] +) + +CALENDAR_CONFIG_FORM = orm.BlockView( + blocks=[ + orm.ActionsBlock( + elements=[ + orm.ButtonElement( + label=":gear: General Calendar Settings", + action=actions.CALENDAR_CONFIG_GENERAL, + value="edit", + ) + ], + ), + orm.SectionBlock( + label=":round_pushpin: Manage Locations", + action=actions.CALENDAR_MANAGE_LOCATIONS, + element=orm.OverflowElement( + options=orm.as_selector_options( + names=["Add Location", "Edit or Deactivate Locations"], + values=["add", "edit"], + ), + ), + ), + orm.SectionBlock( + label=":world_map: Manage AOs", + action=actions.CALENDAR_MANAGE_AOS, + element=orm.OverflowElement( + options=orm.as_selector_options( + names=["Add AO", "Edit or Deactivate AOs"], + values=["add", "edit"], + ), + ), + ), + orm.SectionBlock( + label=":spiral_calendar_pad: Manage Series", + action=actions.CALENDAR_MANAGE_SERIES, + element=orm.OverflowElement( + options=orm.as_selector_options( + names=["Add Series", "Edit or Deactivate Series"], + values=["add", "edit"], + ), + ), + ), + orm.SectionBlock( + label=":date: Manage Single Events", + action=event_instance.CALENDAR_MANAGE_EVENT_INSTANCE, + element=orm.OverflowElement( + options=orm.as_selector_options( + names=["Add Single Event", "Edit or Deactivate Single Events"], + values=["add", "edit"], + ), + ), + ), + orm.SectionBlock( + label=":runner: Manage Event Types", + action=event_type.CALENDAR_MANAGE_EVENT_TYPES, + element=orm.OverflowElement( + options=orm.as_selector_options( + names=["Add Event Type", "Edit or Deactivate Event Types"], + values=["add", "edit"], + ), + ), + ), + orm.SectionBlock( + label=":label: Manage Event Tags", + action=event_tag.CALENDAR_MANAGE_EVENT_TAGS, + element=orm.OverflowElement( + options=orm.as_selector_options( + names=["Add Event Tag", "Edit or Delete Event Tags"], + values=["add", "edit"], + ), + ), + ), + ] +) diff --git a/apps/slackbot/features/calendar/event_instance.py b/apps/slackbot/features/calendar/event_instance.py new file mode 100644 index 00000000..623ba039 --- /dev/null +++ b/apps/slackbot/features/calendar/event_instance.py @@ -0,0 +1,708 @@ +import copy +from datetime import datetime, timedelta +from logging import Logger + +from f3_data_models.models import Attendance, Attendance_x_AttendanceType +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient + +from application.ao.service import AoService +from application.event_instance import EventInstanceData +from application.event_instance.service import EventInstanceService +from application.event_tag.service import EventTagService +from application.event_type.service import EventTypeService +from application.location.service import LocationService +from features.calendar import event_preblast +from infrastructure.api_client import ( + get_api_ao_repository, + get_api_event_instance_repository, + get_api_event_tag_repository, + get_api_event_type_repository, + get_api_location_repository, +) +from utilities.bot_logger import post_bot_log +from utilities.builders import add_loading_form +from utilities.database.orm import SlackSettings +from utilities.helper_functions import ( + _parse_view_private_metadata, + current_date_cst, + get_location_display_name, + get_user, + parse_rich_block, + replace_user_channel_ids, + safe_convert, + safe_get, +) +from utilities.slack import actions, orm + +# --------------------------------------------------------------------------- +# Action / callback ID constants (feature-local) +# --------------------------------------------------------------------------- +CALENDAR_ADD_EVENT_INSTANCE_PREBLAST = "calendar_add_event_instance_preblast" +CALENDAR_ADD_EVENT_INSTANCE_AO = "calendar_add_event_instance_ao" +CALENDAR_ADD_EVENT_INSTANCE_LOCATION = "calendar_add_event_instance_location" +CALENDAR_ADD_EVENT_INSTANCE_TYPE = "calendar_add_event_instance_type" +CALENDAR_ADD_EVENT_INSTANCE_TAG = "calendar_add_event_instance_tag" +CALENDAR_ADD_EVENT_INSTANCE_START_DATE = "calendar_add_event_instance_start_date" +CALENDAR_ADD_EVENT_INSTANCE_END_DATE = "calendar_add_event_instance_end_date" +CALENDAR_ADD_EVENT_INSTANCE_START_TIME = "calendar_add_event_instance_start_time" +CALENDAR_ADD_EVENT_INSTANCE_END_TIME = "calendar_add_event_instance_end_time" +CALENDAR_ADD_EVENT_INSTANCE_NAME = "calendar_add_event_instance_name" +CALENDAR_ADD_EVENT_INSTANCE_HIGHLIGHT = "calendar_add_event_instance_highlight" +CALENDAR_ADD_EVENT_INSTANCE_DOW = "calendar_add_event_instance_dow" +CALENDAR_ADD_EVENT_AO = "calendar_add_event_ao" +CALENDAR_ADD_EVENT_INSTANCE_FREQUENCY = "calendar_add_event_instance_frequency" +CALENDAR_ADD_EVENT_INSTANCE_DESCRIPTION = "calendar_add_event_instance_description" +CALENDAR_ADD_EVENT_INSTANCE_OPTIONS = "calendar_add_event_instance_options" +ADD_EVENT_INSTANCE_CALLBACK_ID = "add_event_instance_callback_id" +CALENDAR_MANAGE_EVENT_INSTANCE = "calendar_manage_event_instance" +EDIT_DELETE_EVENT_INSTANCE_CALLBACK_ID = "edit_delete_event_instance_callback_id" +CALENDAR_MANAGE_EVENT_INSTANCE_AO = "calendar_manage_event_instance_ao" +CALENDAR_MANAGE_EVENT_INSTANCE_DATE = "calendar_manage_event_instance_date" +EVENT_CLOSE_REASON = "event_close_reason" +EVENT_CLOSE_CALLBACK_ID = "event_close_callback_id" + +META_DO_NOT_SEND_AUTO_PREBLASTS = "do_not_send_auto_preblasts" +META_EXCLUDE_FROM_PAX_VAULT = "exclude_from_pax_vault" + + +# --------------------------------------------------------------------------- +# Composition root +# --------------------------------------------------------------------------- + + +def _build_event_instance_service() -> EventInstanceService: + """Build the event-instance service using the production API-backed repository.""" + return EventInstanceService(repository=get_api_event_instance_repository()) + + +def _build_ao_service() -> AoService: + return AoService(repository=get_api_ao_repository()) + + +def _build_location_service() -> LocationService: + return LocationService(repository=get_api_location_repository()) + + +def _build_event_type_service() -> EventTypeService: + return EventTypeService(repository=get_api_event_type_repository()) + + +def _build_event_tag_service() -> EventTagService: + return EventTagService(repository=get_api_event_tag_repository()) + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + + +def manage_event_instances(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + action = safe_get(body, "actions", 0, "selected_option", "value") + + if action == "add": + build_event_instance_add_form(body, client, logger, context, region_record, loading_form=True) + elif action == "edit": + build_event_instance_list_form(body, client, logger, context, region_record, loading_form=True) + + +def build_event_instance_add_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + edit_event_instance: EventInstanceData | None = None, + new_preblast: bool = False, + loading_form: bool = False, +): + parent_metadata = ( + {"event_instance_id": edit_event_instance.id} if edit_event_instance else _parse_view_private_metadata(body) + ) + view_metadata = _parse_view_private_metadata(body) + + if loading_form: + update_view_id = add_loading_form(body, client, new_or_add="add") + else: + update_view_id = None + + title_text = "Add an Event" + form = copy.deepcopy(INSTANCE_FORM) + if new_preblast or (safe_get(view_metadata, "is_preblast") == "True"): + # Add a preblast block if this is a new event + form.blocks.insert( + -1, + orm.InputBlock( + label="Preblast", + action=CALENDAR_ADD_EVENT_INSTANCE_PREBLAST, + element=orm.RichTextInputElement(placeholder="Give us an event preview!"), + optional=False, + ), + ) + parent_metadata.update({"is_preblast": "True"}) + + ao_service = _build_ao_service() + location_service = _build_location_service() + event_type_service = _build_event_type_service() + event_tag_service = _build_event_tag_service() + + aos = ao_service.get_region_aos(region_record.org_id) + locations = location_service.get_org_locations(region_record.org_id) + event_types = event_type_service.get_all_event_types_for_org(region_record.org_id) + event_tags = event_tag_service.get_all_tags_for_org(region_record.org_id) + + form.set_options( + { + CALENDAR_ADD_EVENT_INSTANCE_AO: orm.as_selector_options( + names=[ao.name for ao in aos], + values=[str(ao.id) for ao in aos], + ), + CALENDAR_ADD_EVENT_INSTANCE_LOCATION: orm.as_selector_options( + names=[get_location_display_name(loc) for loc in locations], + values=[str(loc.id) for loc in locations], + ), + CALENDAR_ADD_EVENT_INSTANCE_TYPE: orm.as_selector_options( + names=[et.name for et in event_types], + values=[str(et.id) for et in event_types], + ), + CALENDAR_ADD_EVENT_INSTANCE_TAG: orm.as_selector_options( + names=[tag.name for tag in event_tags], + values=[str(tag.id) for tag in event_tags], + ), + } + ) + + initial_values = {} + + if edit_event_instance: + initial_values = { + CALENDAR_ADD_EVENT_INSTANCE_NAME: edit_event_instance.name, + CALENDAR_ADD_EVENT_INSTANCE_DESCRIPTION: edit_event_instance.description, + CALENDAR_ADD_EVENT_INSTANCE_AO: str(edit_event_instance.org_id), + CALENDAR_ADD_EVENT_INSTANCE_LOCATION: safe_convert(edit_event_instance.location_id, str), + CALENDAR_ADD_EVENT_INSTANCE_TYPE: str(edit_event_instance.event_type_ids[0]) + if edit_event_instance.event_type_ids + else None, # TODO: handle multiple event types + CALENDAR_ADD_EVENT_INSTANCE_START_DATE: safe_convert( + edit_event_instance.start_date, datetime.strftime, ["%Y-%m-%d"] + ), + CALENDAR_ADD_EVENT_INSTANCE_START_TIME: safe_convert( + edit_event_instance.start_time, lambda t: t[:2] + ":" + t[2:] + ), + CALENDAR_ADD_EVENT_INSTANCE_END_TIME: safe_convert( + edit_event_instance.end_time, lambda t: t[:2] + ":" + t[2:] + ), + } + + options = [] + if edit_event_instance.is_private: + options.append("private") + if safe_get(edit_event_instance.meta, META_EXCLUDE_FROM_PAX_VAULT): + options.append("exclude_from_pax_vault") + if safe_get(edit_event_instance.meta, META_DO_NOT_SEND_AUTO_PREBLASTS): + options.append("no_auto_preblasts") + if edit_event_instance.highlight: + options.append("highlight") + if options: + initial_values[CALENDAR_ADD_EVENT_INSTANCE_OPTIONS] = options + if edit_event_instance.event_tag_ids: + initial_values[CALENDAR_ADD_EVENT_INSTANCE_TAG] = [ + str(edit_event_instance.event_tag_ids[0]) + ] # TODO: handle multiple event tags + + # This is triggered when the AO is selected — defaults are loaded for the location + action_id = safe_get(body, "actions", 0, "action_id") + if action_id == CALENDAR_ADD_EVENT_INSTANCE_AO: + form_data = INSTANCE_FORM.get_selected_values(body) + ao_id = safe_convert(safe_get(form_data, action_id), int) + if ao_id: + ao = ao_service.get_ao_by_id(ao_id) + if ao and ao.default_location_id: + initial_values[CALENDAR_ADD_EVENT_INSTANCE_LOCATION] = str(ao.default_location_id) + + form.set_initial_values(initial_values) + form.update_modal( + client=client, + view_id=safe_get(body, "view", "id"), + callback_id=ADD_EVENT_INSTANCE_CALLBACK_ID, + title_text=title_text, + parent_metadata=parent_metadata, + ) + elif update_view_id: + form.set_initial_values(initial_values) + form.update_modal( + client=client, + view_id=update_view_id, + callback_id=ADD_EVENT_INSTANCE_CALLBACK_ID, + title_text=title_text, + parent_metadata=parent_metadata, + ) + else: + form.set_initial_values(initial_values) + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text=title_text, + callback_id=ADD_EVENT_INSTANCE_CALLBACK_ID, + new_or_add="add", + parent_metadata=parent_metadata, + ) + + +def handle_event_instance_add( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + metadata = _parse_view_private_metadata(body) + form = copy.deepcopy(INSTANCE_FORM) + if safe_get(metadata, "is_preblast") == "True": + form.blocks.insert( + -1, + orm.InputBlock( + label="Preblast", + action=CALENDAR_ADD_EVENT_INSTANCE_PREBLAST, + element=orm.RichTextInputElement(placeholder="Give us an event preview!"), + optional=False, + ), + ) + form_data = form.get_selected_values(body) + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + selected_options = safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_OPTIONS) or [] + is_private = "private" in selected_options + exclude_from_pax_vault = "exclude_from_pax_vault" in selected_options + do_not_send_auto_preblasts = "no_auto_preblasts" in selected_options + highlight = "highlight" in selected_options + + # Build / merge meta dict + service = _build_event_instance_service() + if safe_get(metadata, "event_instance_id"): + existing = service.get_by_id(int(metadata["event_instance_id"])) + merged_meta = dict(safe_get(existing, "meta") or {}) if existing else {} + else: + merged_meta = {} + + if exclude_from_pax_vault: + merged_meta[META_EXCLUDE_FROM_PAX_VAULT] = True + else: + merged_meta.pop(META_EXCLUDE_FROM_PAX_VAULT, None) + if do_not_send_auto_preblasts: + merged_meta[META_DO_NOT_SEND_AUTO_PREBLASTS] = True + else: + merged_meta.pop(META_DO_NOT_SEND_AUTO_PREBLASTS, None) + + # Resolve end time + if safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_END_TIME): + end_time: str = safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_END_TIME).replace(":", "") + else: + end_time = ( + datetime.strptime(safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_START_TIME), "%H:%M") + timedelta(hours=1) + ).strftime("%H%M") + + # Slack won't return the selection for location and event type after being defaulted + view_blocks = safe_get(body, "view", "blocks") + location_block = [block for block in view_blocks if block["block_id"] == CALENDAR_ADD_EVENT_INSTANCE_LOCATION][0] + location_initial_value = safe_get(location_block, "element", "initial_option", "value") + location_id = form_data.get(CALENDAR_ADD_EVENT_INSTANCE_LOCATION) or location_initial_value + event_type_block = [block for block in view_blocks if block["block_id"] == CALENDAR_ADD_EVENT_INSTANCE_TYPE][0] + event_type_initial_value = safe_get(event_type_block, "element", "initial_option", "value") + event_type_id = form_data.get(CALENDAR_ADD_EVENT_INSTANCE_TYPE) or event_type_initial_value + + # Apply int conversion to all values if not null + location_id = safe_convert(location_id, int) + event_type_id = safe_convert(event_type_id, int) + org_id = ( + safe_convert( + safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_AO) or safe_get(form_data, CALENDAR_ADD_EVENT_AO), + int, + ) + or region_record.org_id + ) + event_tag_id = safe_convert(safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_TAG, 0), int) + event_tag_ids = [event_tag_id] if event_tag_id else [] + + # Default event name to AO + event type if not provided + if safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_NAME): + event_instance_name = safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_NAME) + else: + ao_service = _build_ao_service() + event_type_service = _build_event_type_service() + ao = ao_service.get_ao_by_id(org_id) + et = event_type_service.get_event_type_by_id(event_type_id) if event_type_id else None + event_instance_name = f"{ao.name if ao else ''} {et.name if et else ''}".strip() + + # Build preblast plain text from rich text block if provided + preblast_text: str | None = None + preblast_rich = safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_PREBLAST) + if preblast_rich: + preblast_text = replace_user_channel_ids( + parse_rich_block(preblast_rich), + region_record, + client, + logger, + ) + + start_date = datetime.strptime(safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_START_DATE), "%Y-%m-%d").date() + start_time = datetime.strptime(safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_START_TIME), "%H:%M").strftime( + "%H%M" + ) + + if safe_get(metadata, "event_instance_id"): + record = service.update_instance( + instance_id=int(metadata["event_instance_id"]), + name=event_instance_name, + org_id=org_id, + start_date=start_date, + start_time=start_time, + end_time=end_time, + description=safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_DESCRIPTION), + location_id=location_id, + event_type_ids=[event_type_id] if event_type_id else [], + event_tag_ids=event_tag_ids, + is_active=True, + is_private=is_private, + meta=merged_meta, + highlight=highlight, + preblast_rich=preblast_rich, + preblast=preblast_text, + ) + post_bot_log( + client=client, + region_record=region_record, + text=f":pencil2: Event edited: {event_instance_name} on {start_date} by <@{slack_user_id or 'app'}>", + logger=logger, + ) + else: + record = service.create_instance( + name=event_instance_name, + org_id=org_id, + start_date=start_date, + start_time=start_time, + end_time=end_time, + description=safe_get(form_data, CALENDAR_ADD_EVENT_INSTANCE_DESCRIPTION), + location_id=location_id, + event_type_ids=[event_type_id] if event_type_id else [], + event_tag_ids=event_tag_ids, + is_active=True, + is_private=is_private, + meta=merged_meta, + highlight=highlight, + preblast_rich=preblast_rich, + preblast=preblast_text, + ) + post_bot_log( + client=client, + region_record=region_record, + text=f":heavy_plus_sign: Event created: {event_instance_name} on {start_date} by <@{slack_user_id or 'app'}>", # noqa: E501 + logger=logger, + ) + + if safe_get(metadata, "is_preblast") == "True": + # If this is for a new unscheduled event, set attendance and post the preblast + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + DbManager.create_record( + Attendance( + event_instance_id=record.id, + user_id=get_user(slack_user_id, region_record, client, logger).user_id, + is_planned=True, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=2)], # 2 = Q + ) + ) + event_preblast.send_preblast(body, client, logger, context, region_record, record.id) + + +def build_event_instance_list_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + update_view_id=None, + loading_form: bool = False, +): + title_text = "Edit/Close/Delete Event" + + if loading_form: + update_view_id = add_loading_form(body, client, new_or_add="add") + + start_date = current_date_cst() + filter_ao_id = None + filter_values = {} + region_org_id = region_record.org_id + + if safe_get(body, "actions", 0, "action_id") in [ + CALENDAR_MANAGE_EVENT_INSTANCE_AO, + CALENDAR_MANAGE_EVENT_INSTANCE_DATE, + ]: + filter_values = orm.BlockView(blocks=copy.deepcopy(EVENT_LIST_FILTERS)).get_selected_values(body) + update_view_id = safe_get(body, "view", "id") + if safe_get(filter_values, CALENDAR_MANAGE_EVENT_INSTANCE_AO): + filter_ao_id = safe_convert(safe_get(filter_values, CALENDAR_MANAGE_EVENT_INSTANCE_AO), int) + if safe_get(filter_values, CALENDAR_MANAGE_EVENT_INSTANCE_DATE): + date_str = safe_get(filter_values, CALENDAR_MANAGE_EVENT_INSTANCE_DATE) + start_date = datetime.strptime(date_str, "%Y-%m-%d").date() + + service = _build_event_instance_service() + records = service.get_region_instances( + region_org_id=region_org_id, + start_date=start_date, + ao_org_id=filter_ao_id, + ) + + ao_service = _build_ao_service() + ao_orgs = ao_service.get_region_aos(region_record.org_id) + + form = orm.BlockView(blocks=copy.deepcopy(EVENT_LIST_FILTERS)) + form.set_options( + { + CALENDAR_MANAGE_EVENT_INSTANCE_AO: orm.as_selector_options( + names=[ao.name for ao in ao_orgs], + values=[str(ao.id) for ao in ao_orgs], + ), + } + ) + form.set_initial_values( + { + CALENDAR_MANAGE_EVENT_INSTANCE_AO: safe_get(filter_values, CALENDAR_MANAGE_EVENT_INSTANCE_AO), + CALENDAR_MANAGE_EVENT_INSTANCE_DATE: safe_get(filter_values, CALENDAR_MANAGE_EVENT_INSTANCE_DATE), + } + ) + + for s in records: + label = f"{s.name} ({s.start_date.strftime('%m/%d/%Y')})"[:50] if s.start_date else (s.name or "")[:50] + if s.series_exception == "closed": + label += " [CLOSED]" + placeholder = "Reopen, Edit, or Delete" + options = ["Reopen", "Edit", "Delete"] + confirm_text = "Are you sure you want to reopen / edit / delete this event?" + else: + placeholder = "Edit, Close, or Delete" + options = ["Edit", "Close", "Delete"] + confirm_text = "Are you sure you want to edit / close / delete this event?" + + form.blocks.append( + orm.SectionBlock( + label=label, + action=f"{actions.EVENT_INSTANCE_EDIT_DELETE}_{s.id}", + element=orm.StaticSelectElement( + placeholder=placeholder, + options=orm.as_selector_options(names=options), + confirm=orm.ConfirmObject( + title="Are you sure?", + text=confirm_text, + confirm="Yes, I'm sure", + deny="Whups, never mind", + ), + ), + ) + ) + + if not records: + form.blocks.append( + orm.SectionBlock( + label="No upcoming events found.", + action="event-instance-empty-notice", + ) + ) + + if update_view_id: + form.update_modal( + client=client, + view_id=update_view_id, + callback_id=EDIT_DELETE_EVENT_INSTANCE_CALLBACK_ID, + title_text=title_text, + submit_button_text="None", + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text=title_text, + callback_id=EDIT_DELETE_EVENT_INSTANCE_CALLBACK_ID, + submit_button_text="None", + new_or_add="add", + ) + + +def handle_event_instance_edit_delete( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + event_instance_id = safe_convert(safe_get(body, "actions", 0, "action_id").split("_")[1], int) + action = safe_get(body, "actions", 0, "selected_option", "value") + user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + section_block_id = safe_get(body, "actions", 0, "block_id") + section_block = next((b for b in safe_get(body, "view", "blocks") if b["block_id"] == section_block_id), None) + event_title = safe_get(section_block, "text", "text") or "an event" + + service = _build_event_instance_service() + + if action == "Edit": + print(f"Editing event instance ID: {event_instance_id}") + event_instance = service.get_by_id(event_instance_id) + print(f"Editing event instance: {event_instance}") + build_event_instance_add_form( + body, client, logger, context, region_record, edit_event_instance=event_instance, loading_form=True + ) + action_text = None + elif action == "Close": + form = copy.deepcopy(EVENT_CLOSE_FORM) + form.update_modal( + client=client, + view_id=safe_get(body, "view", "id"), + callback_id=EVENT_CLOSE_CALLBACK_ID, + title_text="Close Event", + submit_button_text="Close Event", + parent_metadata={"event_instance_id": event_instance_id}, + close_button_text="Cancel", + ) + action_text = f":no_entry_sign: <@{user_id}> closed event: {event_title}" + elif action == "Reopen": + service.reopen_instance(event_instance_id) + build_event_instance_list_form( + body, client, logger, context, region_record, update_view_id=safe_get(body, "view", "id"), loading_form=True + ) + action_text = f":white_check_mark: <@{user_id}> reopened event: {event_title}" + elif action == "Delete": + service.delete_instance(event_instance_id) + build_event_instance_list_form( + body, client, logger, context, region_record, update_view_id=safe_get(body, "view", "id"), loading_form=True + ) + action_text = f":wastebasket: <@{user_id or 'app'}> deleted event: {event_title}" + + if action_text: + post_bot_log( + client=client, + region_record=region_record, + text=action_text, + logger=logger, + ) + + +def handle_event_instance_close( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + metadata = _parse_view_private_metadata(body) + event_instance_id = safe_get(metadata, "event_instance_id") + close_reason = EVENT_CLOSE_FORM.get_selected_values(body).get(EVENT_CLOSE_REASON) + + service = _build_event_instance_service() + service.close_instance(instance_id=event_instance_id, close_reason=close_reason) + + +# --------------------------------------------------------------------------- +# Forms +# --------------------------------------------------------------------------- + +EVENT_CLOSE_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Reason for Closing", + action=EVENT_CLOSE_REASON, + element=orm.PlainTextInputElement(placeholder="Enter the reason for closing this event"), + optional=True, + hint="This will be shown to map users.", + ) + ] +) + +INSTANCE_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="AO", + action=CALENDAR_ADD_EVENT_INSTANCE_AO, + element=orm.StaticSelectElement(placeholder="Select an AO"), + dispatch_action=True, + optional=False, + ), + orm.InputBlock( + label="Location", + action=CALENDAR_ADD_EVENT_INSTANCE_LOCATION, + element=orm.StaticSelectElement(placeholder="Select the location"), + optional=True, + ), + orm.InputBlock( + label="Event Type", + action=CALENDAR_ADD_EVENT_INSTANCE_TYPE, + element=orm.StaticSelectElement(placeholder="Select the event type"), + optional=False, + ), + orm.InputBlock( + label="Event Tag", + action=CALENDAR_ADD_EVENT_INSTANCE_TAG, + element=orm.MultiStaticSelectElement(placeholder="Select the event tag", max_selected_items=1), + optional=True, + ), + orm.InputBlock( + label="Date", + action=CALENDAR_ADD_EVENT_INSTANCE_START_DATE, + element=orm.DatepickerElement(placeholder="Enter the start date"), + optional=False, + ), + orm.InputBlock( + label="Start Time", + action=CALENDAR_ADD_EVENT_INSTANCE_START_TIME, + element=orm.TimepickerElement(placeholder="Enter the start time"), + optional=False, + ), + orm.InputBlock( + label="End Time", + action=CALENDAR_ADD_EVENT_INSTANCE_END_TIME, + element=orm.TimepickerElement(placeholder="Enter the end time"), + hint="If no end time is provided, the event will be defaulted to be one hour long.", + ), + orm.InputBlock( + label="Event Name", + action=CALENDAR_ADD_EVENT_INSTANCE_NAME, + element=orm.PlainTextInputElement(placeholder="Enter the event name"), + hint="If left blank, will default to the AO name + event type.", + ), + orm.InputBlock( + label="Options", + action=CALENDAR_ADD_EVENT_INSTANCE_OPTIONS, + element=orm.CheckboxInputElement( + options=orm.as_selector_options( + names=[ + "Make event private", + "Exclude stats from PAX Vault", + "Do not send auto-preblasts", + "Highlight on Special Events List", + ], + values=[ + "private", + "exclude_from_pax_vault", + "no_auto_preblasts", + "highlight", + ], + descriptions=[ + "Hides event from Maps and Region Pages.", + "Can still be queried from BigQuery or custom dashboards.", + "Opts this event out of automated preblasts.", + "Shown in the calendar image channel if enabled.", + ], + ), + ), + optional=True, + ), + ] +) + +EVENT_LIST_FILTERS = [ + orm.InputBlock( + label="AO Filter", + action=CALENDAR_MANAGE_EVENT_INSTANCE_AO, + element=orm.StaticSelectElement( + placeholder="Select an AO", + ), + optional=True, + dispatch_action=True, + ), + orm.InputBlock( + label="Date Filter", + action=CALENDAR_MANAGE_EVENT_INSTANCE_DATE, + element=orm.DatepickerElement( + placeholder="Select a date", + ), + optional=True, + dispatch_action=True, + ), +] diff --git a/apps/slackbot/features/calendar/event_preblast.py b/apps/slackbot/features/calendar/event_preblast.py new file mode 100644 index 00000000..7970e6c7 --- /dev/null +++ b/apps/slackbot/features/calendar/event_preblast.py @@ -0,0 +1,1158 @@ +import json +import random +import time +from copy import deepcopy +from dataclasses import dataclass +from datetime import datetime, timezone +from logging import Logger +from typing import List + +from f3_data_models.models import ( + Attendance, + Attendance_x_AttendanceType, + AttendanceType, + EventInstance, + EventTag, + EventTag_x_EventInstance, + Location, + Org, +) +from f3_data_models.utils import DbManager +from slack_sdk.errors import SlackApiError +from slack_sdk.web import WebClient +from sqlalchemy import or_ + +from features import backblast, connect +from features.calendar import get_preblast_action_blocks +from utilities import constants +from utilities.bot_logger import post_bot_log +from utilities.builders import add_loading_form +from utilities.database.orm import SlackSettings +from utilities.database.special_queries import ( + event_attendance_query, + get_admin_users, + get_aoq_users, +) +from utilities.helper_functions import ( + current_date_cst, + fix_from_llm_tags, + get_location_display_name, + get_user, + get_user_names, + parse_rich_block, + replace_user_channel_ids, + reupload_file_as_bot, + safe_convert, + safe_get, +) +from utilities.slack import actions, orm + + +@dataclass +class PreblastInfo: + event_record: EventInstance + attendance_records: list[Attendance] + preblast_blocks: list[orm.BaseBlock] + action_blocks: list[orm.BaseElement] + user_is_q: bool = False + attendance_slack_dict: dict[Attendance, str] = None + + +def get_preblast_channel(region_record: SlackSettings, preblast_info: PreblastInfo) -> str | None: + if ( + region_record.default_preblast_destination == constants.CONFIG_DESTINATION_SPECIFIED["value"] + and region_record.preblast_destination_channel + ): + return region_record.preblast_destination_channel + return safe_get(preblast_info.event_record.org.meta, "slack_channel_id") + + +def post_hc_thread_reply( + client: WebClient, + logger: Logger, + region_record: SlackSettings, + preblast_channel: str | None, + preblast_ts: str | None, + slack_user_id: str, + is_hc: bool, + event_instance_id: int | None = None, +) -> None: + """Post an optional announcement in the preblast thread when a user HCs or Un-HCs.""" + option = region_record.hc_announce_option + if not option or option == "off" or not preblast_channel or not preblast_ts: + return + targets = region_record.hc_announce_targets or "both" + if is_hc and targets == "unhc_only": + return + if not is_hc and targets == "hc_only": + return + # Only post the first time a user performs each action (HC or Un-HC) for this event + if event_instance_id is not None: + event_record: EventInstance = DbManager.get(EventInstance, event_instance_id) + meta = event_record.meta or {} + hc_announced = meta.get("hc_announced", {}) + key = "hc" if is_hc else "unhc" + if slack_user_id in (hc_announced.get(key) or []): + return + hc_announced.setdefault(key, []).append(slack_user_id) + meta["hc_announced"] = hc_announced + DbManager.update_record(EventInstance, event_instance_id, {EventInstance.meta: meta}) + user_mention = f"<@{slack_user_id}>" + if option == "snarky": + responses = constants.HC_SNARKY_RESPONSES if is_hc else constants.UNHC_SNARKY_RESPONSES + text = random.choice(responses).format(user=user_mention) + else: + template = constants.HC_STANDARD_RESPONSE if is_hc else constants.UNHC_STANDARD_RESPONSE + text = template.format(user=user_mention) + try: + client.chat_postMessage(channel=preblast_channel, thread_ts=preblast_ts, text=text) + except Exception as e: + logger.error(f"Error posting HC thread reply for event in channel {preblast_channel}: {e}") + + +def preblast_middleware( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + if ( + region_record.org_id is None + or (safe_convert(region_record.migration_date, datetime.strptime, args=["%Y-%m-%d"]) or datetime.now()) + > datetime.now() + ): + connect.build_connect_options_form(body, client, logger, context, region_record) + else: + build_event_preblast_select_form(body, client, logger, context, region_record) + + +def build_event_preblast_select_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + user_id = get_user(safe_get(body, "user", "id") or safe_get(body, "user_id"), region_record, client, logger).user_id + event_records = event_attendance_query( + attendance_filter=[ + Attendance.user_id == user_id, + Attendance.is_planned, + Attendance.attendance_types.any(AttendanceType.id.in_([2, 3])), + ], + event_filter=[ + EventInstance.start_date >= current_date_cst(), + EventInstance.preblast_ts.is_(None), + EventInstance.is_active, + or_( + EventInstance.org_id == region_record.org_id, + EventInstance.org.has(Org.parent_id == region_record.org_id), + ), + ], + ) + + # Section 1: User's upcoming Qs + if event_records: + # Sort by soonest date first + event_records.sort(key=lambda r: r.start_date) + select_blocks = [ + orm.HeaderBlock(label=":point_up: Select From Upcoming Qs:"), + orm.ActionsBlock( + elements=[ + orm.ButtonElement( + label=f"{r.start_date.strftime('%m/%d')} {r.org.name} {' / '.join([t.name for t in r.event_types])}", # noqa: E501 + action=f"{actions.EVENT_PREBLAST_FILL_BUTTON}_{r.id}", + value=str(r.id), + ) + for r in event_records[:4] + ], + ), + ] + if len(event_records) > 4: + select_blocks.append( + orm.InputBlock( + label="All upcoming Qs", + action=actions.EVENT_PREBLAST_SELECT, + dispatch_action=True, + optional=False, + element=orm.StaticSelectElement( + placeholder="Select an event", + options=orm.as_selector_options( + names=[ + f"{r.start_date} {r.org.name} {' / '.join([t.name for t in r.event_types])}"[:50] + for r in event_records + ], + values=[str(r.id) for r in event_records], + ), + ), + hint="If not listed above", + ) + ) + else: + select_blocks = [ + orm.SectionBlock( + label="Looks like you are caught up! You have no upcoming Qs that have not already been posted for." + ), # noqa + ] + + blocks = [ + *select_blocks, + orm.DividerBlock(), + ] + + # Section 2: Events without a Q + blocks += [ + orm.SectionBlock(label="Sign up to Q for an upcoming event from the calendar:"), + orm.ActionsBlock( + elements=[ + orm.ButtonElement(label=":calendar: Open Calendar", action=actions.OPEN_CALENDAR_BUTTON), + ] + ), + orm.DividerBlock(), + ] + + # Section 3: Unscheduled event + blocks += [ + orm.SectionBlock(label="Or, create a preblast for an event *not on the calendar:*"), + orm.ActionsBlock( + elements=[ + orm.ButtonElement( + label="New Unscheduled Event", + action=actions.EVENT_PREBLAST_NEW_BUTTON, + confirm=orm.ConfirmObject( + title="Are you sure?", + text="This option should ONLY BE USED FOR UNSCHEDULED EVENTS that are not listed on the calendar. If this is for a normal, scheduled event, please select it from the lists above.", # noqa + confirm="Yes, I'm sure", + deny="Whups, never mind", + style="danger", + ), + ), + ] + ), + ] + + form = orm.BlockView(blocks=blocks) + update_view_id = safe_get(body, "view", "id") or safe_get(body, actions.LOADING_ID) + if update_view_id: + form.update_modal( + client=client, + view_id=update_view_id, + callback_id=actions.EVENT_PREBLAST_SELECT_CALLBACK_ID, + title_text="Select Preblast", + submit_button_text="None", + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + callback_id=actions.EVENT_PREBLAST_SELECT_CALLBACK_ID, + title_text="Select Preblast", + submit_button_text="None", + ) + + +def handle_event_preblast_select( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + action_id = safe_get(body, "actions", 0, "action_id") or "" + view_id = safe_get(body, "view", "id") + + # Handle fill button click (action_id starts with EVENT_PREBLAST_FILL_BUTTON) + if action_id[: len(actions.EVENT_PREBLAST_FILL_BUTTON)] == actions.EVENT_PREBLAST_FILL_BUTTON: + event_instance_id = safe_convert(safe_get(body, "actions", 0, "value"), int) + # Handle no-Q select with Q assignment + elif action_id == actions.EVENT_PREBLAST_NOQ_SELECT: + event_instance_id = safe_convert(safe_get(body, "actions", 0, "selected_option", "value"), int) + # Assign the user as Q for this event + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + user_id = get_user(slack_user_id, region_record, client, logger).user_id + try: + attendance_record = Attendance( + event_instance_id=event_instance_id, + user_id=user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=2)], # Q type + is_planned=True, + ) + DbManager.create_record(attendance_record) + except Exception as e: + logger.error(f"Error assigning Q for event {event_instance_id}: {e}") + # Handle dropdown select + else: + event_instance_id = safe_convert(safe_get(body, "actions", 0, "selected_option", "value"), int) + + build_event_preblast_form( + body, client, logger, context, region_record, event_instance_id=event_instance_id, update_view_id=view_id + ) + + +def build_event_preblast_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + event_instance_id: int = None, + update_view_id: str = None, +): + if not update_view_id: + loading_view_id = add_loading_form(body, client, new_or_add="add" if safe_get(body, "view", "id") else "new") + + preblast_info = build_preblast_info(body, client, logger, context, region_record, event_instance_id) + record = preblast_info.event_record + view_id = safe_get(body, "view", "id") + action_value = safe_get(body, "actions", 0, "value") or safe_get(body, "actions", 0, "selected_option", "value") + + preblast_channel = get_preblast_channel(region_record, preblast_info) + if action_value == "Edit Preblast" or preblast_info.user_is_q: + form = deepcopy(EVENT_PREBLAST_FORM) + + location_records: list[Location] = DbManager.find_records( + Location, [Location.org_id == region_record.org_id, Location.is_active] + ) # noqa + event_tags: list[EventTag] = DbManager.find_records( + EventTag, [or_(EventTag.specific_org_id == region_record.org_id, EventTag.specific_org_id.is_(None))] + ) + # TODO: filter locations to AO? + # TODO: show hardcoded details (date, time, etc.) + form.set_options( + { + actions.EVENT_PREBLAST_LOCATION: orm.as_selector_options( + names=[get_location_display_name(location) for location in location_records], + values=[str(location.id) for location in location_records], + ), + actions.EVENT_PREBLAST_TAG: orm.as_selector_options( + names=[tag.name for tag in event_tags if tag.name != "Open"], + values=[str(tag.id) for tag in event_tags if tag.name != "Open"], + ), + } + ) + # if start_date is more than 24 hours away, default to sending 24 hours before + print(record.start_date, current_date_cst(), (record.start_date - current_date_cst()).days, record.preblast_ts) # noqa + if ( + record.start_date > current_date_cst() + and (record.start_date - current_date_cst()).days > 1 + and not record.preblast_ts + ): + schedule_default = "Send a day before the event" + else: + schedule_default = "Send now" + initial_values = { + actions.EVENT_PREBLAST_TITLE: record.name, + actions.EVENT_PREBLAST_MOLESKINE_EDIT: record.preblast_rich or region_record.preblast_moleskin_template, + actions.EVENT_PREBLAST_START_TIME: record.start_time[:2] + ":" + record.start_time[2:], + actions.EVENT_PREBLAST_SEND_OPTIONS: schedule_default, + # actions.EVENT_PREBLAST_TAG: safe_convert(getattr(record.event_tags, "id", None), str), + } + if record.location: + initial_values[actions.EVENT_PREBLAST_LOCATION] = str(record.location.id) + if record.event_tags: + initial_values[actions.EVENT_PREBLAST_TAG] = [ + str(record.event_tags[0].id) + ] # TODO: handle multiple event types and current data format + coq_list = [ + s for a, s in preblast_info.attendance_slack_dict.items() if 3 in [t.id for t in a.attendance_types] + ] + if coq_list: + initial_values[actions.EVENT_PREBLAST_COQS] = coq_list + + form.set_initial_values(initial_values) + title_text = "Edit Event Preblast" + submit_button_text = "Update" + + if not preblast_channel or preblast_info.event_record.preblast_ts: + form.blocks = form.blocks[:-1] + if not preblast_channel: + form.blocks.append( + orm.SectionBlock( + label="A slack channel has not been set for this AO or region, so this will not be posted. " + "An admin can set the channel for the AO through Calendar Settings -> Manage AOs or for " + "the region through Backblast & Preblast Settings." + ) + ) + if preblast_info.event_record.preblast_ts and preblast_channel: + form.blocks.append( + orm.InputBlock( + label="How would you like to update the preblast?", + action=actions.EVENT_PREBLAST_UPDATE_MODE, + element=orm.RadioButtonsElement( + options=orm.as_selector_options( + names=["Update preblast", "Repost preblast"], + ), + initial_value="Update preblast", + ), + optional=False, + ) + ) + else: + form.blocks[-1].label = "When would you like to send the preblast?" + form.blocks.append(orm.ActionsBlock(elements=preblast_info.action_blocks)) + else: + blocks = [ + *preblast_info.preblast_blocks, + orm.ActionsBlock(elements=preblast_info.action_blocks), + ] + if preblast_info.event_record.preblast_ts: + blocks.append( + orm.SectionBlock( + label=f"\n*This preblast has been posted, *" # noqa + ) + ) # noqa + + form = orm.BlockView(blocks=blocks) + title_text = "Event Preblast" + submit_button_text = "None" + + metadata = { + "event_instance_id": event_instance_id, + "preblast_ts": str(preblast_info.event_record.preblast_ts), + } + + if update_view_id: + update_view_id = update_view_id + callback_id = actions.EVENT_PREBLAST_CALLBACK_ID + else: + update_view_id = loading_view_id + if view_id: + callback_id = actions.EVENT_PREBLAST_CALLBACK_ID + else: + callback_id = actions.EVENT_PREBLAST_POST_CALLBACK_ID + + form.update_modal( + client=client, + view_id=update_view_id, + title_text=title_text, + submit_button_text=submit_button_text, + parent_metadata=metadata, + callback_id=callback_id, + ) + + +def handle_event_preblast_edit( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + form_data = EVENT_PREBLAST_FORM.get_selected_values(body) + metadata = json.loads(safe_get(body, "view", "private_metadata") or "{}") + event_instance_id = safe_get(metadata, "event_instance_id") + + preblast_send = ( + form_data[actions.EVENT_PREBLAST_SEND_OPTIONS] == "Send now" + or (safe_get(metadata, "preblast_ts") or "None") != "None" + ) + + update_fields = { + EventInstance.name: form_data[actions.EVENT_PREBLAST_TITLE], + EventInstance.location_id: form_data[actions.EVENT_PREBLAST_LOCATION], + EventInstance.preblast_rich: fix_from_llm_tags(form_data[actions.EVENT_PREBLAST_MOLESKINE_EDIT]), + EventInstance.preblast: replace_user_channel_ids( + parse_rich_block(fix_from_llm_tags(form_data[actions.EVENT_PREBLAST_MOLESKINE_EDIT])), + region_record, + client, + logger, + ), + EventInstance.start_time: safe_get(form_data, actions.EVENT_PREBLAST_START_TIME).replace(":", ""), + } + if form_data[actions.EVENT_PREBLAST_IMAGE]: + event_instance_record: EventInstance = DbManager.get(EventInstance, event_instance_id) + event_instance_meta = event_instance_record.meta or {} + file_obj = safe_get(form_data[actions.EVENT_PREBLAST_IMAGE], 0) + file_id = reupload_file_as_bot(file_obj, client, logger, region_record=region_record) or safe_get( + file_obj, "id" + ) + event_instance_meta["preblast_image_slack_file_id"] = file_id + update_fields[EventInstance.meta] = event_instance_meta + + DbManager.update_record(EventInstance, event_instance_id, update_fields) + DbManager.delete_records( + cls=EventTag_x_EventInstance, + filters=[EventTag_x_EventInstance.event_instance_id == event_instance_id], + ) + if form_data[actions.EVENT_PREBLAST_TAG]: + DbManager.create_record( + EventTag_x_EventInstance( + event_instance_id=event_instance_id, + event_tag_id=safe_get(form_data, actions.EVENT_PREBLAST_TAG, 0), + ) + ) + + coq_list = safe_get(form_data, actions.EVENT_PREBLAST_COQS) or [] + user_ids = [get_user(slack_id, region_record, client, logger).user_id for slack_id in coq_list] + # better way to upsert / on conflict do nothing? + DbManager.delete_records( + cls=Attendance, + filters=[ + Attendance.event_instance_id == event_instance_id, + Attendance.attendance_types.any(AttendanceType.id == 3), + ], # COQ type + joinedloads=[Attendance.attendance_x_attendance_types], + ) + if user_ids: + DbManager.delete_records( + cls=Attendance, + filters=[ + Attendance.event_instance_id == event_instance_id, + Attendance.is_planned, + Attendance.user_id.in_(user_ids), + ], + joinedloads=[Attendance.attendance_x_attendance_types], + ) + new_records = [ + Attendance( + event_instance_id=event_instance_id, + user_id=user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=3)], + is_planned=True, + ) + for user_id in user_ids + ] + DbManager.create_records(new_records) + + if preblast_send: + # Get update mode directly from body since it's dynamically added to the form + update_mode = safe_get( + body, + "view", + "state", + "values", + actions.EVENT_PREBLAST_UPDATE_MODE, + actions.EVENT_PREBLAST_UPDATE_MODE, + "selected_option", + "value", + ) + repost = update_mode == "Repost preblast" + send_preblast( + body, + client, + logger, + context, + region_record, + event_instance_id, + repost=repost, + ) + # forms.SUBMIT_FORM_SUCCESS.update_modal( + # client=client, + # view_id=safe_get(body, "view", "id"), + # callback_id="submit_form_success", + # title_text="Preblast Submitted", + # submit_button_text="None", + # ) + + # elif form_data[actions.EVENT_PREBLAST_SEND_OPTIONS] == "Schedule 24 hours before event": + # pass # schedule preblast + else: + pass + + +def _post_blocks(api_call, blocks: list, logger: Logger, max_retries: int = 3, **kwargs): + """Call a Slack API method with blocks, retrying on invalid slack file errors. + + Slack occasionally needs a moment to process a freshly uploaded file before it + can be referenced in a slack_file image block. On that specific error, this + retries with exponential back-off (1 s, 2 s, 4 s) rather than a fixed sleep. + All other errors are re-raised immediately. + """ + for attempt in range(max_retries): + try: + return api_call(blocks=blocks, **kwargs) + except SlackApiError as exc: + is_file_not_ready = exc.response.get("error") == "invalid_blocks" and any( + "slack file" in (e or "") for e in exc.response.get("errors", []) + ) + if is_file_not_ready and attempt < max_retries - 1: + wait = 2**attempt # 1 s, 2 s, 4 s … + logger.warning(f"Slack file not ready (attempt {attempt + 1}/{max_retries}), retrying in {wait}s") + time.sleep(wait) + continue + # TODO: optionally remove the image block as a last resort if the file never becomes ready? + raise + + +def send_preblast( + body: dict = None, + client: WebClient = None, + logger: Logger = None, + context: dict = None, + region_record: SlackSettings = None, + event_instance_id: int = None, + repost: bool = False, +): + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + preblast_info = build_preblast_info(body, client, logger, context, region_record, event_instance_id) + q_attendance = next( + (r for r in preblast_info.attendance_records if any(t.id == 2 for t in r.attendance_types)), None + ) + q_user_id = safe_get(preblast_info.attendance_slack_dict, q_attendance) + q_list = [ + r for r in preblast_info.attendance_records if bool({t.id for t in r.attendance_types}.intersection([2, 3])) + ] + blocks = [ + *preblast_info.preblast_blocks, + *get_preblast_action_blocks(has_q=len(q_list) > 0, event_instance_id=event_instance_id), + ] + if safe_get(preblast_info.event_record.meta, "preblast_image_slack_file_id"): + blocks.insert( + -1, + orm.ImageBlock( + slack_file_id=safe_get(preblast_info.event_record.meta, "preblast_image_slack_file_id"), + alt_text="Preblast Image", + ), + ) + blocks = [b.as_form_field() for b in blocks] + metadata = { + "event_instance_id": event_instance_id, + "attendees": [r.user.id for r in preblast_info.attendance_records], + "qs": [r.user.id for r in q_list], + } + if not body: + # this will happen if called outside a user interaction + username = None + icon_url = None + else: + slack_id = q_user_id or slack_user_id + q_name, q_url = get_user_names([slack_id], logger, client, return_urls=True) + q_name = (q_name or [""])[0] + q_url = q_url[0] + username = f"{q_name} (via F3 Nation)" + icon_url = q_url + preblast_channel = get_preblast_channel(region_record, preblast_info) + + existing_ts = preblast_info.event_record.preblast_ts or safe_get(metadata, "preblast_ts") + if existing_ts and preblast_channel: + if repost: + # Delete the original message and create a new one + try: + client.chat_delete( + channel=preblast_channel, + ts=str(existing_ts), + ) + except Exception as e: + logger.error(f"Error deleting original preblast message for event_instance_id {event_instance_id}: {e}") + # Post new message + try: + res = _post_blocks( + client.chat_postMessage, + blocks, + logger, + channel=preblast_channel, + text="Event Preblast", + metadata={"event_type": "preblast", "event_payload": metadata}, + unfurl_links=False, + username=username, + icon_url=icon_url, + ) + DbManager.update_record(EventInstance, event_instance_id, {EventInstance.preblast_ts: float(res["ts"])}) + except Exception as e: + logger.error(f"Error posting new preblast message for event_instance_id {event_instance_id}: {e}") + else: + # Update existing message + try: + _post_blocks( + client.chat_update, + blocks, + logger, + channel=preblast_channel, + ts=str(existing_ts), + text="Event Preblast", + metadata={"event_type": "preblast", "event_payload": metadata}, + username=username, + icon_url=icon_url, + ) + except Exception as e: + logger.error(f"Error updating preblast message for event_instance_id {event_instance_id}: {e}") + action_text = "reposted" if repost else "updated" + else: + if not preblast_channel: + preblast_channel = client.chat_postMessage( + channel=slack_user_id, + text="Your preblast was saved. However, in order to post it to Slack, you will need to set a preblast " + "channel. This can be done by region " + "admins; either at the AO level by going to Settings -> Calendar Settings -> Manage AOs, " + "or at the region level by going to Settings -> Preblast and Backblast Settings.", + ) + action_text = "saved (no channel)" + else: + res = _post_blocks( + client.chat_postMessage, + blocks, + logger, + channel=preblast_channel, + text="Event Preblast", + metadata={"event_type": "preblast", "event_payload": metadata}, + unfurl_links=False, + username=username, + icon_url=icon_url, + ) + DbManager.update_record(EventInstance, event_instance_id, {EventInstance.preblast_ts: float(res["ts"])}) + action_text = "posted" + log_msg = f":mega: Preblast {action_text} for *{preblast_info.event_record.name}* on *{preblast_info.event_record.start_date}* by <@{slack_user_id or 'app'}>" # noqa: E501 + if preblast_channel and preblast_info.event_record.preblast_ts: + log_msg += f" \n" # noqa: E501 + post_bot_log( + client=client, + region_record=region_record, + text=log_msg, + logger=logger, + ) + + +def build_preblast_info( + body: dict = None, + client: WebClient = None, + logger: Logger = None, + context: dict = None, + region_record: SlackSettings = None, + event_instance_id: int = None, +) -> PreblastInfo: + event_record: EventInstance = DbManager.get(EventInstance, event_instance_id, joinedloads="all") + attendance_records: List[Attendance] = DbManager.find_records( + Attendance, [Attendance.event_instance_id == event_instance_id, Attendance.is_planned], joinedloads="all" + ) + attendance_slack_dict = { + r: next((s.slack_id for s in (r.slack_users or []) if s.slack_team_id == region_record.team_id), None) + for r in attendance_records + } + + action_blocks = [] + # build list of attenance_slack_dict where the value is not None + hc_list = " ".join([f"<@{s}>" for a, s in attendance_slack_dict.items() if s is not None]) + hc_list += " ".join([f"@{a.user.f3_name or 'Unknown'}" for a, s in attendance_slack_dict.items() if s is None]) + hc_list = hc_list if hc_list else "None" + hc_count = len({r.user.id for r in attendance_records}) + + if not body: + # this will happen if called outside a user interaction + user_id = None + user_is_q = False + else: + user_id = get_user( + safe_get(body, "user", "id") or safe_get(body, "user_id"), region_record, client, logger + ).user_id + user_is_q = any( + r.user.id == user_id + for r in attendance_records + if bool({t.id for t in r.attendance_types}.intersection([2, 3])) + ) + + q_list = " ".join( + [ + f"<@{attendance_slack_dict[r]}>" + for r in attendance_records + if bool({t.id for t in r.attendance_types}.intersection([2, 3])) and attendance_slack_dict[r] + ] + ) + q_list += " ".join( + [ + f"@{r.user.f3_name or 'Unknown'}" + for r in attendance_records + if bool({t.id for t in r.attendance_types}.intersection([2, 3])) and not attendance_slack_dict[r] + ] + ) + if not q_list: + q_list = "Open!" + action_blocks.append( + orm.ButtonElement( + label="Take Q", + action=actions.EVENT_PREBLAST_TAKE_Q, + value=str(event_record.id), + ) + ) + elif user_is_q: + action_blocks.append( + orm.ButtonElement( + label="Take myself off Q", + action=actions.EVENT_PREBLAST_REMOVE_Q, + value=str(event_record.id), + ) + ) + + user_hc = any(r.user.id == user_id for r in attendance_records) + if user_hc: + if not user_is_q: + action_blocks.append( + orm.ButtonElement( + label="Un-HC", + action=actions.EVENT_PREBLAST_UN_HC, + value=str(event_record.id), + ) + ) + else: + action_blocks.append( + orm.ButtonElement( + label="HC", + action=actions.EVENT_PREBLAST_HC, + value=str(event_record.id), + ) + ) + + location = "" + if safe_get(event_record.org.meta, "slack_channel_id"): + location += f"<#{event_record.org.meta['slack_channel_id']}>" + if event_record.location: + name = get_location_display_name(event_record.location) + if event_record.location.latitude and event_record.location.longitude: + location += f" - " + else: + location += f" - {name}" + + event_details = f"*Preblast: {event_record.name}*" + event_details += f"\n*Date:* {event_record.start_date.strftime('%A, %B %d')}" + event_details += f"\n*Time:* {event_record.start_time}" + event_details += f"\n*Where:* {location}" + event_details += f"\n*Event Type:* {' / '.join([t.name for t in event_record.event_types])}" + if event_record.event_tags: + event_details += f"\n*Event Tag:* {', '.join([tag.name for tag in event_record.event_tags])}" + event_details += f"\n*Q:* {q_list}" + event_details += f"\n*HC Count:* {hc_count}" + event_details += f"\n*HCs:* {hc_list}" + + preblast_blocks = [ + orm.SectionBlock(label=event_details), + orm.RichTextBlock( + label=event_record.preblast_rich or region_record.preblast_moleskin_template or DEFAULT_PREBLAST + ), + ] + return PreblastInfo( + event_record=event_record, + attendance_records=attendance_records, + preblast_blocks=preblast_blocks, + action_blocks=action_blocks, + user_is_q=user_is_q, + attendance_slack_dict=attendance_slack_dict, + ) + + +def route_preblast_overflow_action( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + action_value: str = body["actions"][0]["selected_option"]["value"] + metadata = safe_get(body, "message", "metadata", "event_payload") + + if action_value.startswith(actions.EVENT_PREBLAST_EDIT): + user_id = get_user( + safe_get(body, "user", "id") or safe_get(body, "user_id"), region_record, client, logger + ).user_id + if constants.ALL_USERS_ARE_ADMINS or (user_id in (safe_get(metadata, "qs") or [])): + user_can_edit = True + else: + admin_users = get_admin_users(region_record.org_id, slack_team_id=region_record.team_id) + aoq_users = get_aoq_users(region_record.org_id) + user_can_edit = any(u[0].id == user_id for u in admin_users) or any(u.id == user_id for u in aoq_users) + if user_can_edit: + body["actions"][0]["action_id"] = "Edit Preblast" + body["actions"][0]["value"] = "Edit Preblast" + build_event_preblast_form( + body, client, logger, context, region_record, event_instance_id=int(action_value.split("_")[-1]) + ) + elif action_value.startswith(actions.PREBLAST_FILL_BACKBLAST_BUTTON): + body["actions"][0]["action_id"] = action_value.split("_")[0] + backblast.build_backblast_form( + body, client, logger, context, region_record, event_instance_id=int(action_value.split("_")[-1]) + ) + elif action_value == actions.NEW_PREBLAST_BUTTON: + body["actions"][0]["action_id"] = action_value + preblast_middleware(body, client, logger, context, region_record) + + +def handle_event_preblast_action( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + action_id = safe_get(body, "actions", 0, "action_id") + metadata = json.loads(safe_get(body, "view", "private_metadata") or "{}") or safe_get( + body, "message", "metadata", "event_payload" + ) + event_instance_id = safe_get(metadata, "event_instance_id") + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + user_id = get_user(slack_user_id, region_record, client, logger).user_id + view_id = safe_get(body, "view", "id") + + if view_id: + if action_id == actions.EVENT_PREBLAST_HC: + DbManager.create_record( + Attendance( + event_instance_id=event_instance_id, + user_id=user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=1)], + is_planned=True, + ) + ) + elif action_id == actions.EVENT_PREBLAST_UN_HC: + DbManager.delete_records( + cls=Attendance, + filters=[ + Attendance.event_instance_id == event_instance_id, + Attendance.user_id == user_id, + Attendance.is_planned, + Attendance.attendance_x_attendance_types.any(Attendance_x_AttendanceType.attendance_type_id == 1), + ], + joinedloads=[Attendance.attendance_x_attendance_types], + ) + elif action_id == actions.EVENT_PREBLAST_TAKE_Q: + DbManager.delete_records( + cls=Attendance, + filters=[ + Attendance.event_instance_id == event_instance_id, + Attendance.user_id == user_id, + Attendance.is_planned, + ], + joinedloads=[Attendance.attendance_x_attendance_types], + ) + DbManager.create_record( + Attendance( + event_instance_id=event_instance_id, + user_id=user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=2)], + is_planned=True, + ) + ) + elif action_id == actions.EVENT_PREBLAST_REMOVE_Q: + DbManager.delete_records( + cls=Attendance, + filters=[ + Attendance.event_instance_id == event_instance_id, + Attendance.user_id == user_id, + Attendance.attendance_x_attendance_types.any( + Attendance_x_AttendanceType.attendance_type_id.in_([2, 3]) + ), + Attendance.is_planned, + ], + joinedloads=[Attendance.attendance_x_attendance_types], + ) + # Touch EventInstance.updated so calendar images are regenerated + DbManager.update_record(EventInstance, event_instance_id, fields={"updated": datetime.now(timezone.utc)}) + if metadata.get("preblast_ts") and metadata["preblast_ts"] != "None": + preblast_info = build_preblast_info(body, client, logger, context, region_record, event_instance_id) + blocks = [ + *preblast_info.preblast_blocks, + *get_preblast_action_blocks( + has_q=len(preblast_info.action_blocks) > 0, event_instance_id=event_instance_id + ), + ] + if safe_get(preblast_info.event_record.meta, "preblast_image_slack_file_id"): + blocks.insert( + -1, + orm.ImageBlock( + slack_file_id=safe_get(preblast_info.event_record.meta, "preblast_image_slack_file_id"), + alt_text="Preblast Image", + ), + ) + blocks = [b.as_form_field() for b in blocks] + + q_name, q_url = get_user_names([slack_user_id], logger, client, return_urls=True) + q_name = (q_name or [""])[0] + q_url = q_url[0] + preblast_channel = get_preblast_channel(region_record, preblast_info) + try: + client.chat_update( + channel=preblast_channel, + ts=metadata["preblast_ts"], + blocks=blocks, + text="Event Preblast", + metadata={"event_type": "preblast", "event_payload": metadata}, + username=f"{q_name} (via F3 Nation)", + icon_url=q_url, + ) + except Exception as e: + logger.error( + f"Error updating preblast message after action {action_id} and event_instance_id {event_instance_id}: {e}" # noqa + ) + if action_id in (actions.EVENT_PREBLAST_HC, actions.EVENT_PREBLAST_UN_HC): + post_hc_thread_reply( + client, + logger, + region_record, + preblast_channel, + metadata["preblast_ts"], + slack_user_id, + is_hc=action_id == actions.EVENT_PREBLAST_HC, + event_instance_id=event_instance_id, + ) + build_event_preblast_form( + body, client, logger, context, region_record, event_instance_id=event_instance_id, update_view_id=view_id + ) + else: + if action_id == actions.EVENT_PREBLAST_HC_UN_HC: + already_hcd = user_id in (safe_get(metadata, "attendees") or []) + if already_hcd: + DbManager.delete_records( + cls=Attendance, + filters=[ + Attendance.event_instance_id == event_instance_id, + Attendance.user_id == user_id, + Attendance.attendance_types.any(AttendanceType.id == 1), + Attendance.is_planned, + ], + joinedloads=[Attendance.attendance_types], + ) + else: + try: + DbManager.create_record( + Attendance( + event_instance_id=event_instance_id, + user_id=user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=1)], + is_planned=True, + ) + ) + except Exception as e: + logger.warning( + f"User {user_id} already marked as HC for event {event_instance_id} or is duplicate: {e}" + ) + preblast_info = build_preblast_info(body, client, logger, context, region_record, event_instance_id) + q_id_list = [ + r.user.id + for r in preblast_info.attendance_records + if bool({t.id for t in r.attendance_types}.intersection([2, 3])) + ] + metadata = { + "event_instance_id": event_instance_id, + "attendees": [r.user.id for r in preblast_info.attendance_records], + "qs": q_id_list, + } + button_blocks = get_preblast_action_blocks(has_q=len(q_id_list) > 0, event_instance_id=event_instance_id) + blocks = [*preblast_info.preblast_blocks, *button_blocks] + if safe_get(preblast_info.event_record.meta, "preblast_image_slack_file_id"): + blocks.insert( + -1, + orm.ImageBlock( + slack_file_id=safe_get(preblast_info.event_record.meta, "preblast_image_slack_file_id"), + alt_text="Preblast Image", + ), + ) + q_name, q_url = get_user_names([slack_user_id], logger, client, return_urls=True) + q_name = (q_name or [""])[0] + q_url = q_url[0] + preblast_channel = get_preblast_channel(region_record, preblast_info) + client.chat_update( + channel=preblast_channel, + ts=body["message"]["ts"], + blocks=[b.as_form_field() for b in blocks], + text="Preblast", + metadata={"event_type": "preblast", "event_payload": metadata}, + username=f"{q_name} (via F3 Nation)", + icon_url=q_url, + ) + post_hc_thread_reply( + client, + logger, + region_record, + preblast_channel, + body["message"]["ts"], + slack_user_id, + is_hc=not already_hcd, + event_instance_id=event_instance_id, + ) + elif action_id == actions.EVENT_PREBLAST_EDIT: + if constants.ALL_USERS_ARE_ADMINS: + user_is_admin = True + else: + admin_users = get_admin_users(region_record.org_id, slack_team_id=region_record.team_id) + user_is_admin = any(u[0].id == user_id for u in admin_users) + if (user_id in (safe_get(metadata, "qs") or [])) or user_is_admin: + build_event_preblast_form( + body, client, logger, context, region_record, event_instance_id=event_instance_id + ) + else: + client.chat_postEphemeral( + channel=body["channel"]["id"], + user=slack_user_id, + text=":warning: Only Qs can edit the preblast! :warning:", + ) + elif action_id == actions.MSG_EVENT_PREBLAST_BUTTON: + event_instance_id = safe_convert(body["actions"][0]["value"], int) + build_event_preblast_form(body, client, logger, context, region_record, event_instance_id=event_instance_id) + elif action_id == actions.EVENT_PREBLAST_TAKE_Q: + event_instance_id = safe_convert(body["actions"][0]["value"], int) + DbManager.delete_records( + cls=Attendance, + filters=[ + Attendance.event_instance_id == event_instance_id, + Attendance.user_id == user_id, + Attendance.is_planned, + ], + joinedloads=[Attendance.attendance_x_attendance_types], + ) + DbManager.create_record( + Attendance( + event_instance_id=event_instance_id, + user_id=user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=2)], + is_planned=True, + ) + ) + build_event_preblast_form(body, client, logger, context, region_record, event_instance_id=event_instance_id) + + +DEFAULT_PREBLAST = { + "type": "rich_text", + "elements": [{"type": "rich_text_section", "elements": [{"text": "No preblast text entered", "type": "text"}]}], +} + +EVENT_PREBLAST_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Title", + action=actions.EVENT_PREBLAST_TITLE, + element=orm.PlainTextInputElement( + placeholder="Event Title", + ), + optional=False, + hint="Studies show that fun titles generate 42% more HC's!", + ), + orm.InputBlock( + label="Location", + action=actions.EVENT_PREBLAST_LOCATION, + element=orm.StaticSelectElement(), + optional=True, + ), + orm.InputBlock( + label="Start Time", + action=actions.EVENT_PREBLAST_START_TIME, + element=orm.TimepickerElement(placeholder="Select start time"), + optional=False, + ), + orm.InputBlock( + label="Co-Qs", + action=actions.EVENT_PREBLAST_COQS, + element=orm.MultiUsersSelectElement(placeholder="Select Co-Qs"), + optional=True, + ), + orm.InputBlock( + label="Event Tag", + action=actions.EVENT_PREBLAST_TAG, + element=orm.MultiStaticSelectElement(placeholder="Select Event Tag", max_selected_items=1), + optional=True, + ), + orm.InputBlock( + label="Preblast", + action=actions.EVENT_PREBLAST_MOLESKINE_EDIT, + element=orm.RichTextInputElement(placeholder="Give us an event preview!"), + optional=False, + ), + orm.InputBlock( + label="Preblast Image", + action=actions.EVENT_PREBLAST_IMAGE, + element=orm.FileInputElement( + placeholder="Upload an image to be included in the preblast", + filetypes=["jpg", "jpeg", "png", "gif"], + max_files=1, + ), + optional=True, + hint="Missing images from iOS? HEICs are a pain, write Tim Cook and tell him to stop using proprietary formats that break everything", # noqa + ), + orm.InputBlock( + label="When to send preblast?", + action=actions.EVENT_PREBLAST_SEND_OPTIONS, + element=orm.RadioButtonsElement( + options=orm.as_selector_options( + names=["Send now", "Send a day before the event"], + ), + initial_value="Send a day before the event", + ), + optional=False, + ), + ] +) diff --git a/apps/slackbot/features/calendar/event_tag.py b/apps/slackbot/features/calendar/event_tag.py new file mode 100644 index 00000000..e619a221 --- /dev/null +++ b/apps/slackbot/features/calendar/event_tag.py @@ -0,0 +1,268 @@ +import copy +import json +from logging import Logger + +from slack_sdk.models.blocks import ( + ContextBlock, + InputBlock, + SectionBlock, +) +from slack_sdk.models.blocks.basic_components import PlainTextObject +from slack_sdk.models.blocks.block_elements import PlainTextInputElement, StaticSelectElement +from slack_sdk.web import WebClient + +from application.event_tag import EventTagData +from application.event_tag.service import EventTagService +from infrastructure.api_client import get_api_event_tag_repository +from utilities.bot_logger import post_bot_log +from utilities.builders import add_loading_form +from utilities.constants import EVENT_TAG_COLORS +from utilities.database.orm import SlackSettings +from utilities.helper_functions import safe_convert, safe_get +from utilities.slack.sdk_orm import SdkBlockView, as_selector_options + +# Action IDs +CALENDAR_MANAGE_EVENT_TAGS = "calendar-manage-event-tags" +CALENDAR_ADD_EVENT_TAG_NEW = "calendar-add-event-tag-new" +CALENDAR_ADD_EVENT_TAG_COLOR = "calendar-add-event-tag-color" +EVENT_TAG_EDIT_DELETE = "event-tag-edit-delete" +CALENDAR_ADD_EVENT_TAG_CALLBACK_ID = "calendar-add-event-tag-id" +EDIT_DELETE_AO_CALLBACK_ID = "edit-delete-ao-id" +CALENDAR_EVENT_TAG_COLORS_IN_USE = "calendar-event-tag-colors-in-use" +CALENDAR_EVENT_TAG_NOTICE = "calendar-event-tag-notice" + + +def _build_event_tag_service() -> EventTagService: + """Build the event-tag service using the production API-backed repository.""" + return EventTagService(repository=get_api_event_tag_repository()) + + +class EventTagViews: + """ + A class for building Slack modal views related to event tags. + """ + + @staticmethod + def build_add_tag_modal(org_tags: list[EventTagData]) -> SdkBlockView: + """ + Constructs the modal for adding a new org-specific event tag. + """ + form = copy.deepcopy(EVENT_TAG_FORM) + color_list = [f"{e.name} - {e.color}" for e in org_tags] + if color_block := form.get_block(CALENDAR_EVENT_TAG_COLORS_IN_USE): + color_block.text.text = f"Colors already in use: \n - {'\n - '.join(color_list)}" + return form + + @staticmethod + def build_edit_tag_modal(tag_to_edit: EventTagData, org_tags: list[EventTagData]) -> SdkBlockView: + """ + Constructs the modal for editing an existing event tag. + """ + form = copy.deepcopy(EVENT_TAG_FORM) + form.set_initial_values( + { + CALENDAR_ADD_EVENT_TAG_NEW: tag_to_edit.name, + CALENDAR_ADD_EVENT_TAG_COLOR: tag_to_edit.color, + } + ) + + # Find the input block for the tag name and change its label + if name_input_block := form.get_block(CALENDAR_ADD_EVENT_TAG_NEW): + name_input_block.label.text = "Edit Event Tag" + name_input_block.element.placeholder.text = "Edit Event Tag" + + # Update the list of colors in use + color_list = [f"{e.name} - {e.color}" for e in org_tags] + if color_block := form.get_block(CALENDAR_EVENT_TAG_COLORS_IN_USE): + color_block.text.text = f"Colors already in use: \n - {'\n - '.join(color_list)}" + + return form + + @staticmethod + def build_tag_list_modal(org_tags: list[EventTagData], notice_text: str | None = None) -> SdkBlockView: + """ + Constructs the modal that lists an organization's event tags, with options to edit or delete them. + """ + blocks = [] + + if notice_text: + blocks.append( + SectionBlock( + text=PlainTextObject(text=notice_text), + block_id=CALENDAR_EVENT_TAG_NOTICE, + ) + ) + + blocks.extend( + [ + SectionBlock( + text=s.name, + block_id=f"{EVENT_TAG_EDIT_DELETE}_{s.id}", + accessory=StaticSelectElement( + placeholder="Edit", # TODO: Change to "Edit / Delete" + options=as_selector_options(names=["Edit", "Delete"]), + # confirm=ConfirmObject( + # title="Are you sure?", + # text="Are you sure you want to edit / delete this Event Tag? This cannot be undone.", + # confirm="Yes, I'm sure", + # deny="Whups, never mind", + # ), + action_id=f"{EVENT_TAG_EDIT_DELETE}_{s.id}", + ), + ) + for s in org_tags + ] + ) + blocks.append( + ContextBlock( + elements=[PlainTextObject(text="Only custom event tags can be edited or deleted.")], + ) + ) + return SdkBlockView(blocks=blocks) + + +def manage_event_tags(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + action = safe_get(body, "actions", 0, "selected_option", "value") + service = _build_event_tag_service() + views = EventTagViews() + + if action == "add": + update_view_id = add_loading_form(body, client, new_or_add="add") + org_tags = service.get_org_event_tags(region_record.org_id) + form = views.build_add_tag_modal(org_tags) + form.update_modal( + client=client, + view_id=update_view_id, + title_text="Add an Event Tag", + callback_id=CALENDAR_ADD_EVENT_TAG_CALLBACK_ID, + ) + elif action == "edit": + org_tags = service.get_org_event_tags(region_record.org_id) + form = views.build_tag_list_modal(org_tags) + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Edit/Delete Event Tags", + callback_id=EDIT_DELETE_AO_CALLBACK_ID, + submit_button_text="None", + new_or_add="add", + ) + + +def handle_event_tag_add(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + form_data = EVENT_TAG_FORM.get_selected_values(body) + event_tag_name = form_data.get(CALENDAR_ADD_EVENT_TAG_NEW) + event_color = form_data.get(CALENDAR_ADD_EVENT_TAG_COLOR) + metadata = json.loads(safe_get(body, "view", "private_metadata") or "{}") + edit_event_tag_id = safe_convert(metadata.get("edit_event_tag_id"), int) + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + service = _build_event_tag_service() + + if event_tag_name and event_color: + if edit_event_tag_id: + service.update_org_specific_tag(edit_event_tag_id, event_tag_name, event_color) + post_bot_log( + client=client, + region_record=region_record, + text=f":pencil2: Event tag edited: {event_tag_name} ({event_color}) by <@{slack_user_id or 'app'}>", + logger=logger, + ) + else: + service.create_org_specific_tag(event_tag_name, event_color, region_record.org_id) + post_bot_log( + client=client, + region_record=region_record, + text=f":heavy_plus_sign: Event tag created: {event_tag_name} ({event_color}) by <@{slack_user_id or 'app'}>", # noqa: E501 + logger=logger, + ) + + +def handle_event_tag_edit_delete( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + action_id = safe_get(body, "actions", 0, "action_id") or "" + event_tag_id = safe_convert(action_id.split("_")[1] if "_" in action_id else None, int) + action = safe_get(body, "actions", 0, "selected_option", "value") + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + if action in ("Edit", "Delete") and event_tag_id is None: + return + + service = _build_event_tag_service() + views = EventTagViews() + + org_tags = service.get_org_event_tags(region_record.org_id) + + if action == "Edit": + update_view_id = add_loading_form(body, client, new_or_add="add") + event_tag = next((t for t in org_tags if t.id == event_tag_id), None) + + if event_tag is None: + form = views.build_tag_list_modal( + org_tags, + notice_text="The selected event tag no longer exists. The list has been refreshed.", + ) + form.update_modal( + client=client, + view_id=update_view_id, + title_text="Edit/Delete Event Tags", + callback_id=EDIT_DELETE_AO_CALLBACK_ID, + submit_button_text="None", + ) + return + + form = views.build_edit_tag_modal(event_tag, org_tags) + form.update_modal( + client=client, + view_id=update_view_id, + title_text="Edit an Event Tag", + callback_id=CALENDAR_ADD_EVENT_TAG_CALLBACK_ID, + parent_metadata={"edit_event_tag_id": event_tag.id}, + ) + elif action == "Delete": + deleted_tag_name = next((t.name for t in org_tags if t.id == event_tag_id), "selected") + service.delete_org_specific_tag(event_tag_id) + org_tags = [t for t in org_tags if t.id != event_tag_id] + forms = views.build_tag_list_modal(org_tags, notice_text=f"The {deleted_tag_name} tag has been deleted.") + forms.update_modal( + client=client, + view_id=safe_get(body, "view", "id"), + title_text="Edit/Delete Event Tags", + callback_id=EDIT_DELETE_AO_CALLBACK_ID, + submit_button_text="None", + ) + post_bot_log( + client=client, + region_record=region_record, + text=f":wastebasket: Event tag deleted: {deleted_tag_name} by <@{slack_user_id or 'app'}>", + logger=logger, + ) + + +EVENT_TAG_FORM = SdkBlockView( + blocks=[ + SectionBlock( + text=PlainTextObject( + text="Note: Event tags are a way to add context about an event. They are different from Event Types, which are used to define the 'what you will do' of an event.", # noqa + ), + ), + InputBlock( + label=PlainTextObject(text="Create a new event tag"), + element=PlainTextInputElement(placeholder=PlainTextObject(text="New event tag")), + block_id=CALENDAR_ADD_EVENT_TAG_NEW, + optional=False, + ), + InputBlock( + label=PlainTextObject(text="Event tag color"), + element=StaticSelectElement( + placeholder=PlainTextObject(text="Select a color"), + options=as_selector_options(names=list(EVENT_TAG_COLORS.keys())), + ), + block_id=CALENDAR_ADD_EVENT_TAG_COLOR, + optional=False, + hint="This is the color that will be shown on the calendar", + ), + SectionBlock(text="Colors already in use:", block_id=CALENDAR_EVENT_TAG_COLORS_IN_USE), + ] +) diff --git a/apps/slackbot/features/calendar/event_type.py b/apps/slackbot/features/calendar/event_type.py new file mode 100644 index 00000000..83afbb7c --- /dev/null +++ b/apps/slackbot/features/calendar/event_type.py @@ -0,0 +1,270 @@ +import copy +import json +from logging import Logger + +from slack_sdk.models.blocks import ContextBlock, InputBlock, SectionBlock +from slack_sdk.models.blocks.basic_components import MarkdownTextObject, PlainTextObject +from slack_sdk.models.blocks.block_elements import PlainTextInputElement, StaticSelectElement +from slack_sdk.web import WebClient + +from application.event_type import EventTypeData +from application.event_type.service import EventTypeService +from infrastructure.api_client import get_api_event_type_repository +from utilities.bot_logger import post_bot_log +from utilities.builders import add_loading_form +from utilities.database.orm import SlackSettings +from utilities.helper_functions import safe_convert, safe_get +from utilities.slack.sdk_orm import SdkBlockView, as_selector_options + +# --------------------------------------------------------------------------- +# Action / callback ID constants (feature-local) +# --------------------------------------------------------------------------- +CALENDAR_MANAGE_EVENT_TYPES = "calendar-manage-event-types" +CALENDAR_ADD_EVENT_TYPE_NEW = "calendar-add-event-type-new" +CALENDAR_ADD_EVENT_TYPE_CATEGORY = "calendar-add-event-type-category" +CALENDAR_ADD_EVENT_TYPE_ACRONYM = "calendar-add-event-type-acronym" +CALENDAR_ADD_EVENT_TYPE_LIST = "calendar-add-event-type-list" +CALENDAR_ADD_EVENT_TYPE_CALLBACK_ID = "calendar-add-event-type-id" +EVENT_TYPE_EDIT_DELETE = "event-type-edit-delete" +EDIT_DELETE_EVENT_TYPE_CALLBACK_ID = "edit-delete-event-type-id" +_EVENT_TYPE_NOTE = "event-type-note" + +# --------------------------------------------------------------------------- +# Static data +# --------------------------------------------------------------------------- +_CATEGORY_LABELS = ["First F", "Second F", "Third F"] +_CATEGORY_VALUES = ["first_f", "second_f", "third_f"] + + +# --------------------------------------------------------------------------- +# Composition root +# --------------------------------------------------------------------------- + + +def _build_event_type_service() -> EventTypeService: + """Build the event-type service using the production API-backed repository.""" + return EventTypeService(repository=get_api_event_type_repository()) + + +# --------------------------------------------------------------------------- +# Views +# --------------------------------------------------------------------------- + + +class EventTypeViews: + """Pure Slack UI construction for event types — no I/O.""" + + @staticmethod + def build_add_type_modal(all_org_types: list[EventTypeData]) -> SdkBlockView: + """Modal for creating a new org-specific event type.""" + form = copy.deepcopy(EVENT_TYPE_FORM) + EventTypeViews._update_type_list_block(form, all_org_types) + return form + + @staticmethod + def build_edit_type_modal(edit_event_type: EventTypeData, all_org_types: list[EventTypeData]) -> SdkBlockView: + """Modal pre-filled with an existing event type's data for editing.""" + form = copy.deepcopy(EVENT_TYPE_FORM) + form.set_initial_values( + { + CALENDAR_ADD_EVENT_TYPE_NEW: edit_event_type.name, + CALENDAR_ADD_EVENT_TYPE_CATEGORY: edit_event_type.event_category or "", + CALENDAR_ADD_EVENT_TYPE_ACRONYM: edit_event_type.acronym or "", + } + ) + form.delete_block(_EVENT_TYPE_NOTE) + if name_block := form.get_block(CALENDAR_ADD_EVENT_TYPE_NEW): + name_block.label.text = "Edit Event Type" + name_block.element.placeholder.text = "Edit Event Type" + EventTypeViews._update_type_list_block(form, all_org_types) + return form + + @staticmethod + def build_type_list_modal(org_types: list[EventTypeData]) -> SdkBlockView: + """List modal showing org-specific event types with edit/delete controls.""" + blocks = [ + ContextBlock( + elements=[PlainTextObject(text="Only region-specific event types can be edited or deleted.")], + ) + ] + blocks.extend( + SectionBlock( + text=MarkdownTextObject(text=f"*{t.name}*: {t.acronym or ''}"), + block_id=f"{EVENT_TYPE_EDIT_DELETE}_{t.id}", + accessory=StaticSelectElement( + placeholder="Edit or Delete", + options=as_selector_options(names=["Edit", "Delete"]), + action_id=f"{EVENT_TYPE_EDIT_DELETE}_{t.id}", + ), + ) + for t in org_types + ) + return SdkBlockView(blocks=blocks) + + @staticmethod + def _update_type_list_block(form: SdkBlockView, all_org_types: list[EventTypeData]) -> None: + type_labels = [f" - {t.name}: {t.acronym or ''}" for t in all_org_types] + if list_block := form.get_block(CALENDAR_ADD_EVENT_TYPE_LIST): + list_block.text.text = "Event types in use:\n\n" + "\n".join(type_labels) + + +# --------------------------------------------------------------------------- +# Handler functions +# --------------------------------------------------------------------------- + + +def manage_event_types(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + action = safe_get(body, "actions", 0, "selected_option", "value") + service = _build_event_type_service() + views = EventTypeViews() + + if action == "add": + update_view_id = add_loading_form(body, client, new_or_add="add") + all_org_types = service.get_all_event_types_for_org(region_record.org_id) + form = views.build_add_type_modal(all_org_types) + form.update_modal( + client=client, + view_id=update_view_id, + title_text="Add an Event Type", + callback_id=CALENDAR_ADD_EVENT_TYPE_CALLBACK_ID, + ) + elif action == "edit": + org_types = service.get_org_specific_event_types(region_record.org_id) + form = views.build_type_list_modal(org_types) + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Edit/Delete Event Types", + callback_id=EDIT_DELETE_EVENT_TYPE_CALLBACK_ID, + submit_button_text="None", + new_or_add="add", + ) + + +def handle_event_type_add(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + form_data = EVENT_TYPE_FORM.get_selected_values(body) + event_type_name = form_data.get(CALENDAR_ADD_EVENT_TYPE_NEW) + event_category = form_data.get(CALENDAR_ADD_EVENT_TYPE_CATEGORY) + event_type_acronym = form_data.get(CALENDAR_ADD_EVENT_TYPE_ACRONYM) + metadata = json.loads(safe_get(body, "view", "private_metadata") or "{}") + edit_event_type_id = safe_convert(metadata.get("edit_event_type_id"), int) + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + service = _build_event_type_service() + + if edit_event_type_id: + service.update_org_specific_type( + edit_event_type_id, + event_type_name or "", + event_type_acronym or "", + event_category or "", + ) + display_acronym = event_type_acronym or (event_type_name[:2] if event_type_name else "") + post_bot_log( + client=client, + region_record=region_record, + text=f":pencil2: Event type edited: {event_type_name} by <@{slack_user_id or 'app'}>", + logger=logger, + ) + elif event_type_name and event_category: + display_acronym = event_type_acronym or event_type_name[:2] + service.create_org_specific_type( + event_type_name, + display_acronym, + event_category, + region_record.org_id, + ) + post_bot_log( + client=client, + region_record=region_record, + text=f":heavy_plus_sign: Event type created: {event_type_name} by <@{slack_user_id or 'app'}>", + logger=logger, + ) + + +def handle_event_type_edit_delete( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + action_id = safe_get(body, "actions", 0, "action_id") or "" + event_type_id = safe_convert(action_id.split("_")[-1] if "_" in action_id else None, int) + action = safe_get(body, "actions", 0, "selected_option", "value") + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + if action in ("Edit", "Delete") and event_type_id is None: + return + + service = _build_event_type_service() + views = EventTypeViews() + + if action == "Edit": + all_org_types = service.get_all_event_types_for_org(region_record.org_id) + event_type = next((t for t in all_org_types if t.id == event_type_id), None) + if event_type is None: + return + form = views.build_edit_type_modal(event_type, all_org_types) + update_view_id = add_loading_form(body, client, new_or_add="add") + form.update_modal( + client=client, + view_id=update_view_id, + title_text="Edit an Event Type", + callback_id=CALENDAR_ADD_EVENT_TYPE_CALLBACK_ID, + parent_metadata={"edit_event_type_id": event_type_id}, + ) + elif action == "Delete": + event_type = next( + (t for t in service.get_all_event_types_for_org(region_record.org_id) if t.id == event_type_id), None + ) + service.delete_org_specific_type(event_type_id) + post_bot_log( + client=client, + region_record=region_record, + text=f":wastebasket: Event type deleted: {event_type.name if event_type else event_type_id} by <@{slack_user_id or 'app'}>", # noqa: E501 + logger=logger, + ) + + +# --------------------------------------------------------------------------- +# Module-level form template (deepcopied before each use) +# --------------------------------------------------------------------------- + +EVENT_TYPE_FORM = SdkBlockView( + blocks=[ + SectionBlock( + text=MarkdownTextObject( + text="Note: Event Types are used to describe what you'll be doing at an event. " + "They are different from Event Tags, which give context to an event without " + "changing what you'll be doing (e.g. 'VQ', 'Convergence', etc.)." + ), + block_id=_EVENT_TYPE_NOTE, + ), + InputBlock( + label=PlainTextObject(text="Create a new event type"), + element=PlainTextInputElement(placeholder="New event type"), + block_id=CALENDAR_ADD_EVENT_TYPE_NEW, + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Select an event category"), + element=StaticSelectElement( + placeholder="Select an event category", + options=as_selector_options(names=_CATEGORY_LABELS, values=_CATEGORY_VALUES), + ), + block_id=CALENDAR_ADD_EVENT_TYPE_CATEGORY, + optional=True, + hint=PlainTextObject(text="This is required for national aggregations (achievements, etc)."), + ), + InputBlock( + label=PlainTextObject(text="Event type acronym"), + element=PlainTextInputElement(placeholder="Two letter acronym", max_length=2), + block_id=CALENDAR_ADD_EVENT_TYPE_ACRONYM, + optional=True, + hint=PlainTextObject( + text="Used in the calendar view to save space. Defaults to first two letters of the name. Must be unique!" # noqa: E501 + ), + ), + SectionBlock( + text=MarkdownTextObject(text="Event types in use:\n\n"), + block_id=CALENDAR_ADD_EVENT_TYPE_LIST, + ), + ] +) diff --git a/apps/slackbot/features/calendar/home.py b/apps/slackbot/features/calendar/home.py new file mode 100644 index 00000000..1c30c595 --- /dev/null +++ b/apps/slackbot/features/calendar/home.py @@ -0,0 +1,763 @@ +import copy +import datetime +import json +import time +from logging import Logger +from typing import List + +import pytz +import requests +from f3_data_models.models import ( + Attendance, + Attendance_x_AttendanceType, + EventInstance, + EventType, + Location, + Org, + Org_Type, + Series_Exception, +) +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient +from sqlalchemy import or_ +from sqlalchemy.exc import IntegrityError + +from features.backblast import build_backblast_form +from features.calendar import event_instance, get_preblast_action_buttons +from features.calendar.event_preblast import ( + build_event_preblast_form, + build_preblast_info, + get_preblast_channel, + post_hc_thread_reply, +) +from utilities import constants +from utilities.constants import GCP_IMAGE_URL, LOCAL_DEVELOPMENT, S3_IMAGE_URL +from utilities.database.orm import SlackSettings +from utilities.database.special_queries import CalendarHomeQuery, get_admin_users, get_aoq_users, home_schedule_query +from utilities.helper_functions import ( + _parse_view_private_metadata, + current_date_cst, + get_location_display_name, + get_user, + safe_convert, + safe_get, + sort_by_name, +) +from utilities.slack import actions, orm + + +def handle_event_preblast_select_button( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + action = safe_get(body, "actions", 0, "action_id") + view_id = safe_get(body, "view", "id") + if action == actions.EVENT_PREBLAST_NEW_BUTTON: + event_instance.build_event_instance_add_form( + body, client, logger, context, region_record, new_preblast=True, loading_form=True + ) + elif action == actions.OPEN_CALENDAR_BUTTON: + build_home_form(body, client, logger, context, region_record, update_view_id=view_id) + + +def build_home_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + update_view_id: str = None, +): + action_id = safe_get(body, "actions", 0, "action_id") + if action_id == actions.CALENDAR_HOME_DATE_FILTER and not safe_get(body, "actions", 0, "selected_date"): + return + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + user_id = get_user(slack_user_id, region_record, client, logger).user_id + + metadata = safe_convert(safe_get(body, "view", "metadata", "event_payload"), json.loads) or {} + user_is_admin = safe_get(metadata, "user_is_admin") + if user_is_admin is None: + admin_users = get_admin_users(region_record.org_id, region_record.team_id) + aoq_users = get_aoq_users(region_record.org_id) + print(f"Admin users: {[u[0].id for u in admin_users]}") + print(f"AOQ users: {[u.id for u in aoq_users]}") + if constants.ALL_USERS_ARE_ADMINS: + user_is_admin = True + else: + user_is_admin = any(u[0].id == user_id for u in admin_users) or any(u.id == user_id for u in aoq_users) + metadata["user_is_admin"] = user_is_admin + + start_time = time.time() + group_by_option = region_record.calendar_group_by_option or "ao" + ao_records = DbManager.find_records( + Org, filters=[Org.parent_id == region_record.org_id, Org.org_type == Org_Type.ao, Org.is_active.is_(True)] + ) + location_records = DbManager.find_records(Location, [Location.org_id == region_record.org_id, Location.is_active]) + location_records2 = DbManager.find_join_records2( + Location, + Org, + [Location.org_id == Org.id, Org.parent_id == region_record.org_id, Location.is_active], + ) + location_records.extend(record[0] for record in location_records2) + location_records.sort(key=sort_by_name(get_location_display_name)) + event_type_records: List[EventType] = DbManager.find_records( + EventType, + filters=[or_(EventType.specific_org_id == region_record.org_id, EventType.specific_org_id.is_(None))], + ) + split_time = time.time() + print(f"AO and Event Type time: {split_time - start_time}") + start_time = time.time() + + blocks = [ + orm.ActionsBlock( + elements=[ + orm.ButtonElement( + ":calendar: Calendar Images", value="calendar", action=actions.OPEN_CALENDAR_IMAGE_BUTTON + ), + orm.ButtonElement( + ":world_map: Nearby Special Events", value="nearby", action=actions.NEARBY_EVENTS_OPEN + ), + ] + ), + orm.DividerBlock(), + orm.SectionBlock(label="*Upcoming Schedule*"), + orm.InputBlock( + label="Filter AOs" if group_by_option == "ao" else "Filter Locations", + action=actions.CALENDAR_HOME_AO_FILTER, + element=orm.MultiStaticSelectElement( + placeholder="Filter AOs" if group_by_option == "ao" else "Filter Locations", + options=orm.as_selector_options( + names=[ao.name for ao in ao_records] + if group_by_option == "ao" + else [get_location_display_name(location) for location in location_records], + values=[str(ao.id) for ao in ao_records] + if group_by_option == "ao" + else [str(location.id) for location in location_records], + ), + ), + dispatch_action=True, + ), + orm.InputBlock( + label="Filter Event Types", + action=actions.CALENDAR_HOME_EVENT_TYPE_FILTER, + element=orm.MultiStaticSelectElement( + placeholder="Filter Event Types", + options=orm.as_selector_options( + names=[event_type.name for event_type in event_type_records], + values=[str(event_type.id) for event_type in event_type_records], + ), + ), + dispatch_action=True, + ), + orm.InputBlock( + label="Start Search Date", + action=actions.CALENDAR_HOME_DATE_FILTER, + element=orm.DatepickerElement( + placeholder="Start Search Date", + ), + dispatch_action=True, + ), + orm.InputBlock( + label="Other options", + action=actions.CALENDAR_HOME_Q_FILTER, + element=orm.CheckboxInputElement( + options=orm.as_selector_options( + names=["Show only open Q slots", "Show only my events", "Include events from nearby regions"], + values=[actions.FILTER_OPEN_Q, actions.FILTER_MY_EVENTS], # , actions.FILTER_NEARBY_REGIONS], + ), + ), + dispatch_action=True, + ), + ] + + if safe_get(body, "view"): + existing_filter_data = orm.BlockView(blocks=blocks).get_selected_values(body) + else: + existing_filter_data = {} + + # Build the filter + start_date = ( + safe_convert( + safe_get(existing_filter_data, actions.CALENDAR_HOME_DATE_FILTER), datetime.datetime.strptime, ["%Y-%m-%d"] + ) + or datetime.datetime.now(tz=pytz.timezone("US/Central")).date() + ) + + selected_group_filter_ids = [ + v + for v in (safe_convert(x, int) for x in (safe_get(existing_filter_data, actions.CALENDAR_HOME_AO_FILTER) or [])) + if v is not None + ] + + filter = [EventInstance.start_date >= start_date, EventInstance.is_active] + if group_by_option == "ao": + filter_org_ids = selected_group_filter_ids or [region_record.org_id] + filter.append(or_(EventInstance.org_id.in_(filter_org_ids), Org.parent_id.in_(filter_org_ids))) + else: + # Keep location grouping constrained to this region and its child AOs. + filter.append(or_(EventInstance.org_id == region_record.org_id, Org.parent_id == region_record.org_id)) + if selected_group_filter_ids: + filter.append(EventInstance.location_id.in_(selected_group_filter_ids)) + + if safe_get(existing_filter_data, actions.CALENDAR_HOME_EVENT_TYPE_FILTER): + event_type_ids = [int(x) for x in safe_get(existing_filter_data, actions.CALENDAR_HOME_EVENT_TYPE_FILTER)] + filter.append(EventType.id.in_(event_type_ids)) + + open_q_only = actions.FILTER_OPEN_Q in (safe_get(existing_filter_data, actions.CALENDAR_HOME_Q_FILTER) or []) + only_users_events = actions.FILTER_MY_EVENTS in ( + safe_get(existing_filter_data, actions.CALENDAR_HOME_Q_FILTER) or [] + ) + # Run the query + # TODO: implement pagination / dynamic limit + split_time = time.time() + print(f"Block building time: {split_time - start_time}") + start_time = time.time() + events: list[CalendarHomeQuery] = home_schedule_query( + user_id, filter, limit=100, open_q_only=open_q_only, only_users_events=only_users_events + ) + + split_time = time.time() + print(f"Home schedule query: {split_time - start_time}") + start_time = time.time() + + # Build the event list + active_date = datetime.date(2020, 1, 1) + block_count = 1 + for event in events: + option_names: List[str] = [] + if event.event.start_date != active_date: + active_date = event.event.start_date + blocks.append(orm.DividerBlock()) + blocks.append(orm.HeaderBlock(label=f":calendar: {active_date.strftime('%A, %B %d')}")) + block_count += 2 + if event.event.series_exception == Series_Exception.closed: + label = f"{event.org.name} {' / '.join(t.name for t in event.event_types)} - CLOSED :no_entry:" + if user_is_admin: + option_names.append("Reopen Event") + else: + option_names.append("Event Closed") + else: + if block_count > 90: + break + if not user_is_admin: + if event.user_q: + option_names.append("Edit Preblast") + else: + option_names.append("View Preblast") + if event.event.highlight: + label = f":star: *{event.event.name}* @ {event.org.name} @ {event.event.start_time}" + elif event.series and event.series.name and event.org.name not in event.series.name: + label = f"{event.series.name} @ {event.org.name} @ {event.event.start_time}" + else: + label = f"{event.org.name} {' / '.join(t.name for t in event.event_types)} @ {event.event.start_time}" # noqa + if event.planned_qs: + label += f" / Q: {event.planned_qs}" + else: + label += " / Q: Open!" + option_names.append("Take Q") + if event.user_q: + label += " :muscle:" + if event.user_attending: + label += " :white_check_mark:" + option_names.append("Un-HC") + else: + option_names.append("HC") + if event.event.preblast_rich: + label += " :pencil:" + if user_is_admin: + option_names.append("Assign Q") + option_names.append("Close Event") + if event.event.start_date > current_date_cst(): + option_names.append("Edit Preblast") + else: + option_names.append("Edit Backblast") + blocks.append( + orm.SectionBlock( + label=label, + element=orm.OverflowElement( + action=f"{actions.CALENDAR_HOME_EVENT}_{event.event.id}", + options=orm.as_selector_options(option_names), + ), + ) + ) + block_count += 1 + + # TODO: add "next page" button + form = orm.BlockView(blocks=blocks) + form.set_initial_values(existing_filter_data) + view_id = update_view_id or safe_get(body, actions.LOADING_ID) or safe_get(body, "view", "id") + split_time = time.time() + print(f"Block build 2 time: {split_time - start_time}") + start_time = time.time() + if view_id: + form.update_modal( + client=client, + view_id=view_id, + title_text="Calendar Home", + callback_id=actions.CALENDAR_HOME_CALLBACK_ID, + submit_button_text="None", + parent_metadata=metadata, + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Calendar Home", + callback_id=actions.CALENDAR_HOME_CALLBACK_ID, + new_or_add="new", + parent_metadata=metadata, + ) + split_time = time.time() + print(f"Sending: {split_time - start_time}") + start_time = time.time() + + +def build_calendar_image_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + this_week_valid = False + next_week_valid = False + if LOCAL_DEVELOPMENT: + try: + this_week_valid = ( + requests.head(S3_IMAGE_URL.format(image_name=region_record.calendar_image_current)).status_code == 200 + ) + next_week_valid = ( + requests.head(S3_IMAGE_URL.format(image_name=region_record.calendar_image_next)).status_code == 200 + ) + except Exception as e: + logger.error(f"Error checking S3 image URLs: {e}") + if this_week_valid and next_week_valid: + this_week_url = S3_IMAGE_URL.format( + image_name=region_record.calendar_image_current or "default.png", + ) + next_week_url = S3_IMAGE_URL.format( + image_name=region_record.calendar_image_next or "default.png", + ) + else: + try: + this_week_valid = ( + requests.head( + GCP_IMAGE_URL.format( + bucket="f3nation-calendar-images", + image_name=region_record.calendar_image_current or "default.png", + ) + ).status_code + == 200 + ) + next_week_valid = ( + requests.head( + GCP_IMAGE_URL.format( + bucket="f3nation-calendar-images", + image_name=region_record.calendar_image_next or "default.png", + ) + ).status_code + == 200 + ) + except Exception as e: + logger.error(f"Error checking GCP image URLs: {e}") + if this_week_valid and next_week_valid: + this_week_url = GCP_IMAGE_URL.format( + bucket="f3nation-calendar-images", + image_name=region_record.calendar_image_current or "default.png", + ) + next_week_url = GCP_IMAGE_URL.format( + bucket="f3nation-calendar-images", + image_name=region_record.calendar_image_next or "default.png", + ) + if this_week_valid and next_week_valid: + blocks = [ + orm.ImageBlock(label="This week's schedule", alt_text="Current", image_url=this_week_url), + orm.ImageBlock(label="Next week's schedule", alt_text="Next", image_url=next_week_url), + ] + else: + blocks = [orm.SectionBlock(label="No calendar images available. Please wait for them to generate.")] + form = orm.BlockView(blocks=blocks) + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Calendar Images", + callback_id=actions.CALENDAR_HOME_CALLBACK_ID, + new_or_add="add", + submit_button_text="None", + ) + + +ASSIGN_Q_FORM = orm.BlockView( + blocks=[ + orm.SectionBlock(label="Assign Q to this event"), + orm.InputBlock( + label="Select User", + action=actions.CALENDAR_HOME_ASSIGN_Q_USER, + element=orm.MultiUsersSelectElement( + placeholder="Select a user to assign to the Q", + initial_value=None, + max_selected_items=1, + ), + ), + orm.InputBlock( + label="Select Co-Qs (optional)", + action=actions.CALENDAR_HOME_ASSIGN_Q_CO_QS, + element=orm.MultiUsersSelectElement( + placeholder="Select users to assign as Co-Qs", + initial_value=None, + ), + optional=True, + ), + ] +) + + +def build_assign_q_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + event_instance_id: int, + update_view_id: str = None, +): + event_instance = DbManager.get(EventInstance, event_instance_id, joinedloads=[EventInstance.org]) + attendance = DbManager.find_records( + Attendance, + filters=[Attendance.event_instance_id == event_instance_id], + joinedloads=[Attendance.slack_users, Attendance.attendance_types], + ) + + form = copy.deepcopy(ASSIGN_Q_FORM) + form.blocks[0].label = ( + f"*AO:* {event_instance.org.name}\n" + + f"*Event:* {event_instance.name}\n" + + f"*Date:* {event_instance.start_date.strftime('%A, %B %d')}\n" + + f"*Start Time:* {event_instance.start_time or 'TBD'}\n" + ) + + existing_q_slack_users = [a.slack_users for a in attendance if any(at.type == "Q" for at in a.attendance_types)] + if existing_q_slack_users: + slack_user_id = [su.slack_id for su in existing_q_slack_users[0] if su.slack_team_id == region_record.team_id] + print(f"Existing Q slack user: {slack_user_id}") + form.set_initial_values({actions.CALENDAR_HOME_ASSIGN_Q_USER: slack_user_id[:1] if slack_user_id else None}) + existing_co_q_slack_users = [ + a.slack_users for a in attendance if any(at.type == "Co-Q" for at in a.attendance_types) + ] + if existing_co_q_slack_users: + slack_user_ids = [] + for slack_user in existing_co_q_slack_users: + slack_user_id = [su.slack_id for su in slack_user if su.slack_team_id == region_record.team_id] + slack_user_ids.append(safe_get(slack_user_id, 0)) + print(f"Existing Co-Q slack users: {slack_user_ids}") + form.set_initial_values({actions.CALENDAR_HOME_ASSIGN_Q_CO_QS: slack_user_ids}) + + metadata = { + "event_instance_id": event_instance_id, + "update_view_id": update_view_id, + "event_instance_name": event_instance.name, + "event_instance_date": event_instance.start_date.strftime("%A, %B %d"), + } + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Assign Q", + callback_id=actions.HOME_ASSIGN_Q_CALLBACK_ID, + submit_button_text="Assign Q", + parent_metadata=metadata, + new_or_add="add", + ) + + +def handle_assign_q_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + form_data = ASSIGN_Q_FORM.get_selected_values(body) + metadata = safe_convert(safe_get(body, "view", "private_metadata"), json.loads) or {} + event_instance_id = safe_convert(safe_get(metadata, "event_instance_id"), int) + event_instance_name = safe_get(metadata, "event_instance_name") + event_instance_date = safe_get(metadata, "event_instance_date") + + # Get the selected user and co-Qs + q_slack_user_ids = safe_get(form_data, actions.CALENDAR_HOME_ASSIGN_Q_USER) or [] + q_slack_user_id = safe_get(q_slack_user_ids, 0) + q_user_id = get_user(q_slack_user_id, region_record, client, logger).user_id if q_slack_user_id else None + co_qs_slack_ids = safe_get(form_data, actions.CALENDAR_HOME_ASSIGN_Q_CO_QS) or [] + co_qs_user_ids = [get_user(co_q, region_record, client, logger).user_id for co_q in co_qs_slack_ids] + + # Existing attendance records + existing_attendance_records = DbManager.find_records( + Attendance, + filters=[Attendance.event_instance_id == event_instance_id], + joinedloads=[Attendance.slack_users, Attendance.attendance_types], + ) + + # Delete existing Q / Co-Q attendance records entirely + q_changed = False + for ea in existing_attendance_records: + if any(at.type in ["Q", "Co-Q"] for at in ea.attendance_types): + q_changed = True + DbManager.delete_records( + cls=Attendance_x_AttendanceType, + filters=[Attendance_x_AttendanceType.attendance_id == ea.id], + ) + DbManager.delete_records( + cls=Attendance, + filters=[Attendance.id == ea.id], + ) + + # Touch EventInstance.updated so calendar images are regenerated when Q changes + if q_changed or q_user_id or co_qs_user_ids: + DbManager.update_record( + EventInstance, event_instance_id, fields={"updated": datetime.datetime.now(datetime.timezone.utc)} + ) + + # Existing attendance records again (probably a better way to do this) + existing_attendance_records = DbManager.find_records( + Attendance, + filters=[Attendance.event_instance_id == event_instance_id], + joinedloads=[Attendance.slack_users, Attendance.attendance_types], + ) + + if q_user_id: + if q_user_id in [ea.user_id for ea in existing_attendance_records]: + attendance_record = next((ea for ea in existing_attendance_records if ea.user_id == q_user_id), None) + if attendance_record and 2 not in [at.id for at in attendance_record.attendance_types]: + DbManager.create_record( + Attendance_x_AttendanceType(attendance_id=attendance_record.id, attendance_type_id=2) + ) + else: + DbManager.create_record( + Attendance( + event_instance_id=event_instance_id, + user_id=q_user_id, + is_planned=True, + attendance_x_attendance_types=[ + Attendance_x_AttendanceType(attendance_type_id=2) # Q + ], + ) + ) + + if co_qs_user_ids: + for co_q_user_id in co_qs_user_ids: + if co_q_user_id in [ea.user_id for ea in existing_attendance_records]: + attendance_record = next((ea for ea in existing_attendance_records if ea.user_id == co_q_user_id), None) + if attendance_record and 3 not in [at.id for at in attendance_record.attendance_types]: + DbManager.create_record( + Attendance_x_AttendanceType(attendance_id=attendance_record.id, attendance_type_id=3) + ) + else: + DbManager.create_record( + Attendance( + event_instance_id=event_instance_id, + user_id=co_q_user_id, + is_planned=True, + attendance_x_attendance_types=[ + Attendance_x_AttendanceType(attendance_type_id=3) # Co-Q + ], + ) + ) + + # Update the home view if needed + update_view_id = safe_get(metadata, "update_view_id") + if update_view_id: + build_home_form(body, client, logger, context, region_record, update_view_id=update_view_id) + + # Send messages to the assigned users + if q_slack_user_id and q_slack_user_id != user_id: + msg = f"<@{user_id}> has assigned you to Q {event_instance_name} on {event_instance_date}. Use the button below to set the preblast." # noqa + blocks: List[orm.BaseBlock] = [ + orm.SectionBlock(label=msg), + orm.ActionsBlock( + elements=[ + orm.ButtonElement( + label="Fill Out Preblast", + value=str(event_instance_id), + style="primary", + action=actions.MSG_EVENT_PREBLAST_BUTTON, + ), + ], + ), + ] + client.chat_postMessage( + channel=q_slack_user_id, + text=msg, + blocks=[b.as_form_field() for b in blocks], + ) + for co_q_slack_id in co_qs_slack_ids: + if co_q_slack_id != user_id: + client.chat_postMessage( + channel=co_q_slack_id, + text=f"<@{user_id}> has assigned you to Co-Q {event_instance_name} on {event_instance_date}.", # noqa + ) + + +def handle_home_event(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + event_instance_id = safe_convert(safe_get(body, "actions", 0, "action_id").split("_")[1], int) + action = safe_get(body, "actions", 0, "selected_option", "value") + user_id = get_user(safe_get(body, "user", "id"), region_record, client, logger).user_id + view_id = safe_get(body, "view", "id") + update_post = False + hc_action: bool | None = None # True = HC, False = Un-HC, None = not an HC action + + if action in ["View Preblast", "Edit Preblast"]: + build_event_preblast_form(body, client, logger, context, region_record, event_instance_id=event_instance_id) + elif action == "Edit Backblast": + build_backblast_form(body, client, logger, context, region_record, event_instance_id=event_instance_id) + elif action == "Take Q": + attendance_record = DbManager.find_records( + Attendance, + filters=[ + Attendance.event_instance_id == event_instance_id, + Attendance.user_id == user_id, + Attendance.is_planned, + ], + joinedloads=[Attendance.attendance_x_attendance_types], + ) + if attendance_record: + if 2 not in attendance_record[0].attendance_x_attendance_types: + DbManager.create_record( + Attendance_x_AttendanceType(attendance_id=attendance_record[0].id, attendance_type_id=2) + ) + else: + DbManager.create_record( + Attendance( + event_instance_id=event_instance_id, + user_id=user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=2)], + is_planned=True, + ) + ) + + # TODO: build the q / preblast form + update_post = True + build_home_form(body, client, logger, context, region_record, update_view_id=view_id) + elif action == "HC": + try: + DbManager.create_record( + Attendance( + event_instance_id=event_instance_id, + user_id=user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=1)], + is_planned=True, + ) + ) + update_post = True + hc_action = True + build_home_form(body, client, logger, context, region_record, update_view_id=view_id) + except IntegrityError as e: + logger.warning( + f"User {user_id} already has an attendance record for event instance {event_instance_id}, ignoring HC: {e}" # noqa + ) + update_post = False + elif action == "Un-HC": + DbManager.delete_records( + cls=Attendance, + filters=[ + Attendance.event_instance_id == event_instance_id, + Attendance.user_id == user_id, + Attendance.attendance_types.any(Attendance_x_AttendanceType.attendance_type_id == 1), + Attendance.is_planned, + ], + joinedloads=[Attendance.attendance_types], + ) + update_post = True + hc_action = False + build_home_form(body, client, logger, context, region_record, update_view_id=view_id) + elif action == "Assign Q": + build_assign_q_form( + body, client, logger, context, region_record, event_instance_id=event_instance_id, update_view_id=view_id + ) + elif action == "Close Event": + form = copy.deepcopy(event_instance.EVENT_CLOSE_FORM) + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + callback_id=actions.EVENT_CLOSE_HOME_CALLBACK_ID, + title_text="Close Event", + submit_button_text="Close Event", + parent_metadata={"event_instance_id": event_instance_id}, + new_or_add="add", + close_button_text="Cancel", + ) + update_post = False + # build_home_form(body, client, logger, context, region_record, update_view_id=view_id) + elif action == "Reopen Event": + DbManager.update_record(EventInstance, event_instance_id, fields={EventInstance.series_exception: None}) + update_post = True + build_home_form(body, client, logger, context, region_record, update_view_id=view_id) + + if update_post: + preblast_info = build_preblast_info(body, client, logger, context, region_record, event_instance_id) + if preblast_info.event_record.preblast_ts: + q_list = [ + r.user.id + for r in preblast_info.attendance_records + if bool({t.id for t in r.attendance_types}.intersection([2, 3])) + ] # noqa + blocks = [ + *preblast_info.preblast_blocks, + orm.ActionsBlock( + elements=get_preblast_action_buttons(has_q=len(q_list) > 0, event_instance_id=event_instance_id) + ), + ] + blocks = [b.as_form_field() for b in blocks] + metadata = { + "event_instance_id": event_instance_id, + "attendees": [r.user.id for r in preblast_info.attendance_records], + "qs": q_list, + } + try: + client.chat_update( + channel=get_preblast_channel(region_record, preblast_info), + ts=safe_get(metadata, "preblast_ts") or str(preblast_info.event_record.preblast_ts), + blocks=blocks, + text="Event Preblast", + metadata={"event_type": "preblast", "event_payload": metadata}, + ) + except Exception as e: + logger.error(f"Error updating preblast post, posting a new one: {e}") + client.chat_postMessage( + channel=get_preblast_channel(region_record, preblast_info), + blocks=blocks, + text="Event Preblast", + metadata={"event_type": "preblast", "event_payload": metadata}, + ) + if hc_action is not None: + post_hc_thread_reply( + client, + logger, + region_record, + get_preblast_channel(region_record, preblast_info), + safe_get(metadata, "preblast_ts") or str(preblast_info.event_record.preblast_ts), + safe_get(body, "user", "id"), + is_hc=hc_action, + ) + + elif action == "edit": + pass + + +def handle_event_instance_close( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + metadata = _parse_view_private_metadata(body) + event_instance_id = safe_get(metadata, "event_instance_id") + close_reason = event_instance.EVENT_CLOSE_FORM.get_selected_values(body).get(event_instance.EVENT_CLOSE_REASON) + event_instance_meta = safe_get(DbManager.get(EventInstance, event_instance_id), "meta") or {} + event_instance_meta["series_exception_reason"] = close_reason + prior_view_id = safe_get(body, "view", "previous_view_id") + + DbManager.update_record( + EventInstance, + event_instance_id, + fields={ + EventInstance.series_exception: Series_Exception.closed, + EventInstance.meta: event_instance_meta, + }, + ) + build_home_form(body, client, logger, context, region_record, update_view_id=prior_view_id) diff --git a/apps/slackbot/features/calendar/location.py b/apps/slackbot/features/calendar/location.py new file mode 100644 index 00000000..62a7195e --- /dev/null +++ b/apps/slackbot/features/calendar/location.py @@ -0,0 +1,454 @@ +import copy +import json +from logging import Logger + +from slack_sdk.models.blocks import InputBlock, SectionBlock +from slack_sdk.models.blocks.basic_components import PlainTextObject +from slack_sdk.models.blocks.block_elements import NumberInputElement, PlainTextInputElement, StaticSelectElement +from slack_sdk.web import WebClient + +from application.location import LocationData +from application.location.service import LocationService +from infrastructure.api_client import get_api_location_repository +from utilities.bot_logger import post_bot_log +from utilities.builders import add_loading_form +from utilities.database.orm import SlackSettings +from utilities.helper_functions import ( + MapUpdateData, + get_location_display_name, + safe_convert, + safe_get, + trigger_map_revalidation, +) +from utilities.slack.sdk_orm import SdkBlockView, as_selector_options + +# --------------------------------------------------------------------------- +# Action / callback ID constants (feature-local) +# --------------------------------------------------------------------------- +CALENDAR_MANAGE_LOCATIONS = "calendar-manage-locations" +CALENDAR_ADD_AO_NEW_LOCATION = "calendar-add-ao-new-location" +LOCATION_EDIT_DELETE = "location-edit-delete" +ADD_LOCATION_CALLBACK_ID = "add-location-id" +EDIT_DELETE_LOCATION_CALLBACK_ID = "edit-delete-location-id" +_LOCATION_NOTICE = "location-notice" + +# Form field IDs +_LOCATION_NAME = "calendar_add_location_name" +_LOCATION_DESCRIPTION = "calendar_add_location_description" +_LOCATION_LAT = "calendar_add_location_lat" +_LOCATION_LON = "calendar_add_location_lon" +_LOCATION_STREET = "calendar-add-location-street" +_LOCATION_STREET2 = "calendar-add-location-street2" +_LOCATION_CITY = "calendar-add-location-city" +_LOCATION_STATE = "calendar-add-location-state" +_LOCATION_ZIP = "calendar-add-location-zip" +_LOCATION_COUNTRY = "calendar-add-location-country" + +# --------------------------------------------------------------------------- +# Composition root +# --------------------------------------------------------------------------- + + +def _build_location_service() -> LocationService: + """Build the location service using the production API-backed repository.""" + return LocationService(repository=get_api_location_repository()) + + +# --------------------------------------------------------------------------- +# Form template (module-level, deepcopied before use) +# --------------------------------------------------------------------------- + +LOCATION_FORM = SdkBlockView( + blocks=[ + InputBlock( + label=PlainTextObject(text="Location Name"), + block_id=_LOCATION_NAME, + element=PlainTextInputElement( + action_id=_LOCATION_NAME, + placeholder=PlainTextObject(text="ie Central Park - Main Entrance"), + ), + optional=False, + hint=PlainTextObject( + text="Use the actual name of the location, ie park name, etc. " + "You will define the F3 AO name when you create AOs." + ), + ), + InputBlock( + label=PlainTextObject(text="Description"), + block_id=_LOCATION_DESCRIPTION, + element=PlainTextInputElement( + action_id=_LOCATION_DESCRIPTION, + placeholder=PlainTextObject( + text="Notes about the meetup spot, ie 'Meet at the flagpole near the entrance'" + ), + multiline=True, + ), + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Latitude"), + block_id=_LOCATION_LAT, + element=NumberInputElement( + action_id=_LOCATION_LAT, + placeholder=PlainTextObject(text="ie 34.0522"), + min_value="-90", + max_value="90", + is_decimal_allowed=True, + ), + optional=False, + ), + InputBlock( + label=PlainTextObject(text="Longitude"), + block_id=_LOCATION_LON, + element=NumberInputElement( + action_id=_LOCATION_LON, + placeholder=PlainTextObject(text="ie -118.2437"), + min_value="-180", + max_value="180", + is_decimal_allowed=True, + ), + optional=False, + ), + InputBlock( + label=PlainTextObject(text="Location Street Address"), + block_id=_LOCATION_STREET, + element=PlainTextInputElement( + action_id=_LOCATION_STREET, + placeholder=PlainTextObject(text="ie 123 Main St."), + ), + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Location Address Line 2"), + block_id=_LOCATION_STREET2, + element=PlainTextInputElement( + action_id=_LOCATION_STREET2, + placeholder=PlainTextObject(text="ie Suite 200"), + ), + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Location City"), + block_id=_LOCATION_CITY, + element=PlainTextInputElement( + action_id=_LOCATION_CITY, + placeholder=PlainTextObject(text="ie Los Angeles"), + ), + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Location State"), + block_id=_LOCATION_STATE, + element=PlainTextInputElement( + action_id=_LOCATION_STATE, + placeholder=PlainTextObject(text="ie CA"), + ), + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Location Zip"), + block_id=_LOCATION_ZIP, + element=PlainTextInputElement( + action_id=_LOCATION_ZIP, + placeholder=PlainTextObject(text="ie 90210"), + ), + optional=True, + ), + InputBlock( + label=PlainTextObject(text="Location Country"), + block_id=_LOCATION_COUNTRY, + element=PlainTextInputElement( + action_id=_LOCATION_COUNTRY, + placeholder=PlainTextObject(text="ie USA"), + ), + optional=True, + hint=PlainTextObject(text="If outside the US, please enter the country name."), + ), + ] +) + + +# --------------------------------------------------------------------------- +# Views +# --------------------------------------------------------------------------- + + +class LocationViews: + """Pure Slack UI construction for locations — no I/O.""" + + @staticmethod + def build_add_modal() -> SdkBlockView: + """Return a blank add-location form.""" + return copy.deepcopy(LOCATION_FORM) + + @staticmethod + def build_edit_modal(location: LocationData) -> SdkBlockView: + """Return the add-location form pre-filled with *location*'s data.""" + form = copy.deepcopy(LOCATION_FORM) + initial: dict = {_LOCATION_NAME: location.name} + if location.description: + initial[_LOCATION_DESCRIPTION] = location.description + if location.latitude is not None: + initial[_LOCATION_LAT] = location.latitude + if location.longitude is not None: + initial[_LOCATION_LON] = location.longitude + if location.address_street: + initial[_LOCATION_STREET] = location.address_street + if location.address_street2: + initial[_LOCATION_STREET2] = location.address_street2 + if location.address_city: + initial[_LOCATION_CITY] = location.address_city + if location.address_state: + initial[_LOCATION_STATE] = location.address_state + if location.address_zip: + initial[_LOCATION_ZIP] = location.address_zip + if location.address_country: + initial[_LOCATION_COUNTRY] = location.address_country + form.set_initial_values(initial) + return form + + @staticmethod + def build_list_modal(locations: list[LocationData], notice_text: str | None = None) -> SdkBlockView: + """Return a modal listing locations with edit/delete controls.""" + blocks = [] + if len(locations) == 0: + notice_text = "No locations found. Please add a location to create AOs with a meetup spot." + + if notice_text: + blocks.append( + SectionBlock( + text=PlainTextObject(text=notice_text), + block_id=_LOCATION_NOTICE, + ) + ) + blocks.extend( + [ + SectionBlock( + text=PlainTextObject(text=get_location_display_name(loc)), + block_id=f"{LOCATION_EDIT_DELETE}_{loc.id}", + accessory=StaticSelectElement( + placeholder="Edit or Delete", + options=as_selector_options(names=["Edit", "Delete"]), + action_id=f"{LOCATION_EDIT_DELETE}_{loc.id}", + ), + ) + for loc in locations + ] + ) + return SdkBlockView(blocks=blocks) + + +# --------------------------------------------------------------------------- +# Handler functions +# --------------------------------------------------------------------------- + + +def manage_locations(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + action = safe_get(body, "actions", 0, "selected_option", "value") + + if action == "add": + build_location_add_form(body, client, logger, context, region_record, loading_form=True) + elif action == "edit": + _build_location_list_form(body, client, logger, context, region_record) + + +def build_location_add_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + edit_location: LocationData | None = None, + loading_form: bool = False, + update_view_id: str | None = None, +): + """Build and display the add/edit location modal. + + This function is also registered directly as an action handler in routing.py + for the CALENDAR_ADD_AO_NEW_LOCATION action (opening a location form from + within the AO creation flow). + """ + if loading_form and not update_view_id: + update_view_id = add_loading_form(body, client, new_or_add="add") + + action_id = safe_get(body, "actions", 0, "action_id") + views = LocationViews() + + if edit_location: + form = views.build_edit_modal(edit_location) + title_text = "Edit Location" + else: + form = views.build_add_modal() + title_text = "Add a Location" + + # Build private_metadata to carry context back to the submit handler. + parent_metadata: dict = {} + if action_id == CALENDAR_ADD_AO_NEW_LOCATION: + from features.calendar import ao + + parent_metadata["update_view_id"] = safe_get(body, "view", "id") + form_data = ao.AO_FORM.get_selected_values(body) + parent_metadata.update(form_data) + if edit_location: + parent_metadata["location_id"] = edit_location.id + + if update_view_id: + form.update_modal( + client=client, + view_id=update_view_id, + title_text=title_text, + callback_id=ADD_LOCATION_CALLBACK_ID, + parent_metadata=parent_metadata, + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text=title_text, + callback_id=ADD_LOCATION_CALLBACK_ID, + new_or_add="add", + parent_metadata=parent_metadata, + ) + + +def handle_location_add(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + form_data = LOCATION_FORM.get_selected_values(body) + metadata = safe_convert(safe_get(body, "view", "private_metadata"), json.loads) + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + latitude = safe_convert(safe_get(form_data, _LOCATION_LAT), float) + longitude = safe_convert(safe_get(form_data, _LOCATION_LON), float) + + service = _build_location_service() + + location_id = safe_get(metadata, "location_id") + if location_id: + service.update_location( + location_id=int(location_id), + name=safe_get(form_data, _LOCATION_NAME), + org_id=region_record.org_id, + description=safe_get(form_data, _LOCATION_DESCRIPTION), + latitude=latitude, + longitude=longitude, + address_street=safe_get(form_data, _LOCATION_STREET), + address_street2=safe_get(form_data, _LOCATION_STREET2), + address_city=safe_get(form_data, _LOCATION_CITY), + address_state=safe_get(form_data, _LOCATION_STATE), + address_zip=safe_get(form_data, _LOCATION_ZIP), + address_country=safe_get(form_data, _LOCATION_COUNTRY), + ) + trigger_map_revalidation(action="map.updated", map_update_data=MapUpdateData(locationId=int(location_id))) + created_location_id = int(location_id) + else: + new_location = service.create_location( + name=safe_get(form_data, _LOCATION_NAME), + org_id=region_record.org_id, + description=safe_get(form_data, _LOCATION_DESCRIPTION), + latitude=latitude, + longitude=longitude, + address_street=safe_get(form_data, _LOCATION_STREET), + address_street2=safe_get(form_data, _LOCATION_STREET2), + address_city=safe_get(form_data, _LOCATION_CITY), + address_state=safe_get(form_data, _LOCATION_STATE), + address_zip=safe_get(form_data, _LOCATION_ZIP), + address_country=safe_get(form_data, _LOCATION_COUNTRY), + ) + trigger_map_revalidation(action="map.created", map_update_data=MapUpdateData(locationId=new_location.id)) + created_location_id = new_location.id + + if safe_get(metadata, "update_view_id"): + from features.calendar import ao + from utilities.slack import actions as slack_actions + + update_metadata = { + slack_actions.CALENDAR_ADD_AO_NAME: safe_get(metadata, slack_actions.CALENDAR_ADD_AO_NAME), + slack_actions.CALENDAR_ADD_AO_DESCRIPTION: safe_get(metadata, slack_actions.CALENDAR_ADD_AO_DESCRIPTION), + slack_actions.CALENDAR_ADD_AO_CHANNEL: safe_get(metadata, slack_actions.CALENDAR_ADD_AO_CHANNEL), + slack_actions.CALENDAR_ADD_AO_LOCATION: str(created_location_id), + slack_actions.CALENDAR_ADD_AO_TYPE: safe_get(metadata, slack_actions.CALENDAR_ADD_AO_TYPE), + } + ao.build_ao_add_form( + body, + client, + logger, + context, + region_record, + update_view_id=metadata["update_view_id"], + update_metadata=update_metadata, + ) + + action_text = ( + f":pencil2: Location edited: {safe_get(form_data, _LOCATION_NAME)} by <@{slack_user_id or 'app'}>" + if location_id + else f":heavy_plus_sign: Location created: {safe_get(form_data, _LOCATION_NAME)} by <@{slack_user_id or 'app'}>" + ) + post_bot_log(client=client, region_record=region_record, text=action_text, logger=logger) + + +def handle_location_edit_delete( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + action_id = safe_get(body, "actions", 0, "action_id") or "" + location_id = safe_convert(action_id.split("_")[1] if "_" in action_id else None, int) + action = safe_get(body, "actions", 0, "selected_option", "value") + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + if action not in ("Edit", "Delete") or location_id is None: + return + + service = _build_location_service() + + if action == "Edit": + location = service.get_location_by_id(location_id) + if location is None: + return + build_location_add_form( + body, + client, + logger, + context, + region_record, + edit_location=location, + loading_form=True, + ) + elif action == "Delete": + locations = service.get_org_locations(region_record.org_id) + deleted_name = next((get_location_display_name(loc) for loc in locations if loc.id == location_id), "selected") + service.delete_location(location_id) + trigger_map_revalidation(action="map.deleted", map_update_data=MapUpdateData(locationId=location_id)) + remaining = [loc for loc in locations if loc.id != location_id] + remaining.sort(key=lambda loc: get_location_display_name(loc).lower()) + views = LocationViews() + form = views.build_list_modal(remaining, notice_text=f"The {deleted_name} location has been deleted.") + form.update_modal( + client=client, + view_id=safe_get(body, "view", "id"), + title_text="Edit/Delete a Location", + callback_id=EDIT_DELETE_LOCATION_CALLBACK_ID, + submit_button_text="None", + ) + post_bot_log( + client=client, + region_record=region_record, + text=f":wastebasket: Location deleted: {deleted_name} by <@{slack_user_id or 'app'}>", + logger=logger, + ) + + +def _build_location_list_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + service = _build_location_service() + locations = service.get_org_locations(region_record.org_id) + locations.sort(key=lambda loc: get_location_display_name(loc).lower()) + + views = LocationViews() + form = views.build_list_modal(locations) + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Edit/Delete a Location", + callback_id=EDIT_DELETE_LOCATION_CALLBACK_ID, + submit_button_text="None", + new_or_add="add", + ) diff --git a/apps/slackbot/features/calendar/nearby_events.py b/apps/slackbot/features/calendar/nearby_events.py new file mode 100644 index 00000000..8b457632 --- /dev/null +++ b/apps/slackbot/features/calendar/nearby_events.py @@ -0,0 +1,508 @@ +import datetime +import json +import math +from dataclasses import dataclass +from logging import Logger +from typing import List, Optional + +from f3_data_models.models import ( + Attendance, + Attendance_x_AttendanceType, + EventInstance, + Location, + Org, + Org_x_SlackSpace, + SlackSpace, +) +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient + +from utilities.database.orm import SlackSettings +from utilities.helper_functions import ( + REGION_RECORDS, + current_date_cst, + get_user, + safe_convert, + safe_get, +) +from utilities.slack import actions, orm + +# ────────────────────────────────────────────────────────────────────────────── +# Constants +# ────────────────────────────────────────────────────────────────────────────── + +DISTANCE_OPTIONS = ["25 miles", "50 miles", "100 miles", "200 miles"] +DISTANCE_VALUES = ["25", "50", "100", "200"] +SORT_OPTIONS = ["Sort by: Distance", "Sort by: Date"] +SORT_VALUES = ["distance", "date"] +DEFAULT_DISTANCE = "50" +DEFAULT_SORT = "distance" +DEFAULT_DAYS_AHEAD = 60 +MAX_EVENTS_DISPLAYED = 20 + + +# ────────────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────────────── + + +def haversine_miles(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Return the great-circle distance in miles between two lat/lon points.""" + R = 3958.8 # Earth radius in miles + phi1, phi2 = math.radians(lat1), math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlambda = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2 + return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +def _get_target_settings(region_org_id: int) -> Optional[SlackSettings]: + """Return SlackSettings for a region org, or None if not found or not on Slack.""" + ox = DbManager.find_first_record(Org_x_SlackSpace, [Org_x_SlackSpace.org_id == region_org_id]) + if not ox: + return None + slack_space = DbManager.get(SlackSpace, ox.slack_space_id) + if not slack_space: + return None + settings = REGION_RECORDS.get(slack_space.team_id) + if not settings and slack_space.settings: + try: + settings = SlackSettings(**slack_space.settings) + except Exception: + pass + return settings + + +def _get_event_preblast_channel(target_settings: Optional[SlackSettings], event: EventInstance) -> Optional[str]: + """Return the preblast channel for a target region's event.""" + if ( + target_settings + and target_settings.default_preblast_destination == "specified_channel" + and target_settings.preblast_destination_channel + ): + return target_settings.preblast_destination_channel + if event.org and event.org.meta: + return event.org.meta.get("slack_channel_id") + return None + + +# ────────────────────────────────────────────────────────────────────────────── +# Data classes +# ────────────────────────────────────────────────────────────────────────────── + + +@dataclass +class NearbyEventRow: + event: EventInstance + ao_org: Org + region_org: Optional[Org] + distance_miles: float + hc_count: int + user_attending: bool + + +# ────────────────────────────────────────────────────────────────────────────── +# Service +# ────────────────────────────────────────────────────────────────────────────── + + +def get_region_midpoint(org_id: int) -> Optional[tuple]: + """ + Return (avg_lat, avg_lon) centroid of all geolocated locations for this region and its AOs. + Returns None if no locations with lat/lon are found. + """ + region_locations: List[Location] = DbManager.find_records( + Location, + filters=[Location.org_id == org_id, Location.is_active], + ) + ao_location_rows = DbManager.find_join_records2( + Location, + Org, + [Location.org_id == Org.id, Org.parent_id == org_id, Location.is_active], + ) + ao_locations = [row[0] for row in ao_location_rows] + + all_locations = region_locations + ao_locations + geolocated = [loc for loc in all_locations if loc.latitude and loc.longitude] + + if not geolocated: + return None + + avg_lat = sum(loc.latitude for loc in geolocated) / len(geolocated) + avg_lon = sum(loc.longitude for loc in geolocated) / len(geolocated) + return avg_lat, avg_lon + + +def get_nearby_special_events( + org_id: int, + center_lat: float, + center_lon: float, + max_miles: float, + user_id: Optional[int], + days_ahead: int = DEFAULT_DAYS_AHEAD, +) -> List[NearbyEventRow]: + """ + Return upcoming special events (highlight=True) from other regions + within max_miles of (center_lat, center_lon). + """ + today = current_date_cst() + end_date = today + datetime.timedelta(days=days_ahead) + + # Fetch all highlighted active upcoming events + all_events: List[EventInstance] = DbManager.find_records( + EventInstance, + filters=[ + EventInstance.highlight, + EventInstance.is_active, + EventInstance.start_date >= today, + EventInstance.start_date <= end_date, + ], + joinedloads=[EventInstance.org, EventInstance.location], + ) + + # Filter: AO-level events from other regions with location data within range + candidates: List[tuple] = [] + for event in all_events: + if not event.org or not event.org.parent_id: + continue # skip region-level events without an AO parent + if event.org.parent_id == org_id: + continue # skip current region's events + + loc = event.location + if not loc or not loc.latitude or not loc.longitude: + continue # skip events without geolocated location + + dist = haversine_miles(center_lat, center_lon, loc.latitude, loc.longitude) + if dist <= max_miles: + candidates.append((event, dist)) + + if not candidates: + return [] + + # Batch-fetch region orgs + region_ids = list({event.org.parent_id for event, _ in candidates}) + region_orgs: List[Org] = DbManager.find_records(Org, [Org.id.in_(region_ids)]) + region_org_dict = {org.id: org for org in region_orgs} + + # Batch-fetch all planned attendance for candidate events + event_ids = [event.id for event, _ in candidates] + attendances: List[Attendance] = DbManager.find_records( + Attendance, + filters=[ + Attendance.event_instance_id.in_(event_ids), + Attendance.is_planned, + ], + ) + hc_count_dict: dict = {} + user_attending_set: set = set() + for att in attendances: + hc_count_dict[att.event_instance_id] = hc_count_dict.get(att.event_instance_id, 0) + 1 + if user_id and att.user_id == user_id: + user_attending_set.add(att.event_instance_id) + + rows = [] + for event, dist in candidates: + rows.append( + NearbyEventRow( + event=event, + ao_org=event.org, + region_org=region_org_dict.get(event.org.parent_id), + distance_miles=dist, + hc_count=hc_count_dict.get(event.id, 0), + user_attending=event.id in user_attending_set, + ) + ) + return rows + + +# ────────────────────────────────────────────────────────────────────────────── +# View builder +# ────────────────────────────────────────────────────────────────────────────── + + +def _build_nearby_events_blocks( + rows: List[NearbyEventRow], + max_miles: float, + sort_by: str, + center_found: bool, +) -> List[orm.BaseBlock]: + """Build the block list for the nearby events modal.""" + blocks: List[orm.BaseBlock] = [ + orm.InputBlock( + label="Max distance", + action=actions.NEARBY_EVENTS_DISTANCE, + element=orm.StaticSelectElement( + placeholder="Select distance", + options=orm.as_selector_options(names=DISTANCE_OPTIONS, values=DISTANCE_VALUES), + initial_value=str(int(max_miles)), + ), + dispatch_action=True, + ), + orm.InputBlock( + label="Sort by", + action=actions.NEARBY_EVENTS_SORT, + element=orm.StaticSelectElement( + placeholder="Sort by", + options=orm.as_selector_options(names=SORT_OPTIONS, values=SORT_VALUES), + initial_value=sort_by, + ), + dispatch_action=True, + ), + orm.DividerBlock(), + ] + + if not center_found: + blocks.append( + orm.SectionBlock(label=":warning: No location data found for your region. Distances cannot be calculated.") + ) + + if not rows: + blocks.append( + orm.SectionBlock( + label=( + f":mag: No special events found within *{int(max_miles)} miles* " + f"in the next {DEFAULT_DAYS_AHEAD} days." + ) + ) + ) + return blocks + + if sort_by == "date": + rows = sorted(rows, key=lambda r: (r.event.start_date, r.event.start_time or "")) + else: + rows = sorted(rows, key=lambda r: r.distance_miles) + + rows = rows[:MAX_EVENTS_DISPLAYED] + + active_date = None + for row in rows: + if row.event.start_date != active_date: + active_date = row.event.start_date + blocks.append(orm.DividerBlock()) + blocks.append(orm.HeaderBlock(label=f":calendar: {active_date.strftime('%A, %B %d')}")) + + region_name = row.region_org.name if row.region_org else "Unknown Region" + ao_name = row.ao_org.name if row.ao_org else "Unknown AO" + dist_str = f"{row.distance_miles:.0f} mi" + time_str = row.event.start_time or "TBD" + hc_emoji = " :white_check_mark:" if row.user_attending else "" + location_name = row.event.location.name if row.event.location else "Maps Link" + location_link = f"" + + label = ( + f":star: *{row.event.name}*\n" + f":house: {region_name} • {ao_name} ({dist_str})\n" + f":round_pushpin: {location_link}\n" + f":clock1: {time_str} :muscle: {row.hc_count} HC(s){hc_emoji}" + ) + + action_value = json.dumps({"event_instance_id": row.event.id, "region_org_id": row.ao_org.parent_id}) + if row.user_attending: + action_element = orm.ButtonElement( + label="Un-HC", + action=actions.NEARBY_EVENTS_UN_HC, + value=action_value, + ) + else: + action_element = orm.ButtonElement( + label="HC :raised_hands:", + action=actions.NEARBY_EVENTS_HC, + value=action_value, + ) + + blocks.append(orm.SectionBlock(label=label, element=action_element)) + + return blocks + + +# ────────────────────────────────────────────────────────────────────────────── +# Handlers +# ────────────────────────────────────────────────────────────────────────────── + + +def build_nearby_events_modal( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + """Open or refresh the Nearby Special Events modal.""" + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + user = get_user(slack_user_id, region_record, client, logger) + user_id = user.user_id if user else None + + existing_view_id = safe_get(body, "view", "id") + loading_id = safe_get(body, actions.LOADING_ID) + action_id = safe_get(body, "actions", 0, "action_id") + + # Read filter state from current view (if any) + state_values = safe_get(body, "view", "state", "values") or {} + max_miles_str = ( + safe_get( + state_values, + actions.NEARBY_EVENTS_DISTANCE, + actions.NEARBY_EVENTS_DISTANCE, + "selected_option", + "value", + ) + or DEFAULT_DISTANCE + ) + sort_by = ( + safe_get( + state_values, + actions.NEARBY_EVENTS_SORT, + actions.NEARBY_EVENTS_SORT, + "selected_option", + "value", + ) + or DEFAULT_SORT + ) + max_miles = safe_convert(max_miles_str, float) or float(DEFAULT_DISTANCE) + + # Compute region midpoint and fetch events + center = None + if region_record.org_id: + try: + center = get_region_midpoint(region_record.org_id) + except Exception as e: + logger.error(f"Error computing region midpoint for org_id {region_record.org_id}: {e}") + + rows: List[NearbyEventRow] = [] + if center: + try: + rows = get_nearby_special_events( + org_id=region_record.org_id, + center_lat=center[0], + center_lon=center[1], + max_miles=max_miles, + user_id=user_id, + ) + except Exception as e: + logger.error(f"Error fetching nearby special events: {e}") + + blocks = _build_nearby_events_blocks( + rows=rows, + max_miles=max_miles, + sort_by=sort_by, + center_found=center is not None, + ) + + form = orm.BlockView(blocks=blocks) + title = "Nearby Special Events" + callback_id = actions.NEARBY_EVENTS_CALLBACK_ID + + if loading_id: + # Opened via loading-modal mechanism (e.g. from a Slack message button with loading=True) + form.update_modal( + client=client, + view_id=loading_id, + title_text=title, + callback_id=callback_id, + submit_button_text="None", + ) + elif existing_view_id and action_id == actions.NEARBY_EVENTS_OPEN: + # Button clicked while inside another modal → push as sub-modal + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text=title, + callback_id=callback_id, + new_or_add="add", + submit_button_text="None", + ) + elif existing_view_id: + # Filter dispatch action or HC action from within the nearby events modal → update in place + form.update_modal( + client=client, + view_id=existing_view_id, + title_text=title, + callback_id=callback_id, + submit_button_text="None", + ) + else: + # Direct message button click with no existing modal → open new modal + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text=title, + callback_id=callback_id, + new_or_add="new", + submit_button_text="None", + ) + + +def handle_nearby_events_hc_action( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + """HC or Un-HC on a nearby region's special event, then refresh the modal.""" + from features.calendar.event_preblast import post_hc_thread_reply + + action_id = safe_get(body, "actions", 0, "action_id") + action_value = json.loads(safe_get(body, "actions", 0, "value") or "{}") + event_instance_id = safe_convert(action_value.get("event_instance_id"), int) + region_org_id = safe_convert(action_value.get("region_org_id"), int) + + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + user = get_user(slack_user_id, region_record, client, logger) + user_id = user.user_id if user else None + + is_hc = action_id == actions.NEARBY_EVENTS_HC + + if is_hc: + try: + DbManager.create_record( + Attendance( + event_instance_id=event_instance_id, + user_id=user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=1)], + is_planned=True, + ) + ) + except Exception as e: + logger.error(f"Error creating HC attendance for nearby event {event_instance_id}: {e}") + else: + try: + DbManager.delete_records( + cls=Attendance, + filters=[ + Attendance.event_instance_id == event_instance_id, + Attendance.user_id == user_id, + Attendance.is_planned, + Attendance.attendance_x_attendance_types.any(Attendance_x_AttendanceType.attendance_type_id == 1), + ], + joinedloads=[Attendance.attendance_x_attendance_types], + ) + except Exception as e: + logger.error(f"Error deleting HC attendance for nearby event {event_instance_id}: {e}") + + # Post HC announcement to the target region's preblast thread + if region_org_id: + try: + target_settings = _get_target_settings(region_org_id) + if target_settings and target_settings.bot_token: + event_record: EventInstance = DbManager.get( + EventInstance, event_instance_id, joinedloads=[EventInstance.org] + ) + if event_record and event_record.preblast_ts: + preblast_channel = _get_event_preblast_channel(target_settings, event_record) + target_client = WebClient(token=target_settings.bot_token) + post_hc_thread_reply( + client=target_client, + logger=logger, + region_record=target_settings, + preblast_channel=preblast_channel, + preblast_ts=str(event_record.preblast_ts), + slack_user_id=slack_user_id, + is_hc=is_hc, + event_instance_id=event_instance_id, + ) + except Exception as e: + logger.error(f"Error posting HC announcement to target region for event {event_instance_id}: {e}") + + # Refresh the modal with updated HC state + build_nearby_events_modal(body, client, logger, context, region_record) diff --git a/apps/slackbot/features/calendar/series.py b/apps/slackbot/features/calendar/series.py new file mode 100644 index 00000000..c1fba952 --- /dev/null +++ b/apps/slackbot/features/calendar/series.py @@ -0,0 +1,619 @@ +import copy +import json +from datetime import datetime, timedelta +from logging import Logger + +from f3_data_models.models import Day_Of_Week, Event_Cadence +from slack_sdk.web import WebClient + +from application.ao.service import AoService +from application.event_tag.service import EventTagService +from application.event_type.service import EventTypeService +from application.location.service import LocationService +from application.series import SeriesData +from application.series.service import SeriesService +from infrastructure.api_client import ( + get_api_ao_repository, + get_api_event_tag_repository, + get_api_event_type_repository, + get_api_location_repository, + get_api_series_repository, +) +from utilities import constants +from utilities.bot_logger import post_bot_log +from utilities.builders import add_loading_form +from utilities.database.orm import SlackSettings +from utilities.helper_functions import ( + MapUpdateData, + _parse_view_private_metadata, + get_location_display_name, + safe_convert, + safe_get, + trigger_map_revalidation, +) +from utilities.slack import actions, orm + +META_DO_NOT_SEND_AUTO_PREBLASTS = "do_not_send_auto_preblasts" +META_EXCLUDE_FROM_PAX_VAULT = "exclude_from_pax_vault" + + +# --------------------------------------------------------------------------- +# Composition root +# --------------------------------------------------------------------------- + + +def _build_series_service() -> SeriesService: + return SeriesService(repository=get_api_series_repository()) + + +def _build_ao_service() -> AoService: + return AoService(repository=get_api_ao_repository()) + + +def _build_location_service() -> LocationService: + return LocationService(repository=get_api_location_repository()) + + +def _build_event_type_service() -> EventTypeService: + return EventTypeService(repository=get_api_event_type_repository()) + + +def _build_event_tag_service() -> EventTagService: + return EventTagService(repository=get_api_event_tag_repository()) + + +# --------------------------------------------------------------------------- +# Handlers +# --------------------------------------------------------------------------- + + +def manage_series(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + action = safe_get(body, "actions", 0, "selected_option", "value") + + if action == "add": + build_series_add_form(body, client, logger, context, region_record, loading_form=True) + elif action == "edit": + build_series_list_form(body, client, logger, context, region_record, loading_form=True) + + +def build_series_add_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + edit_event: SeriesData | None = None, + new_preblast: bool = False, + loading_form: bool = False, +): + metadata = _parse_view_private_metadata(body) + series_service = _build_series_service() + + if safe_get(metadata, "series_id"): + edit_event = series_service.get_by_id(metadata["series_id"]) + parent_metadata = metadata + else: + parent_metadata = {"series_id": edit_event.id} if edit_event else {} + + if loading_form: + update_view_id = add_loading_form(body, client, new_or_add="add") + else: + update_view_id = None + + title_text = "Edit a Series" if edit_event else "Add a Series" + form = copy.deepcopy(SERIES_FORM) + parent_metadata.update({"is_series": "True"}) + + ao_service = _build_ao_service() + location_service = _build_location_service() + event_type_service = _build_event_type_service() + event_tag_service = _build_event_tag_service() + + aos = ao_service.get_region_aos(region_record.org_id) + locations = location_service.get_org_locations(region_record.org_id) + event_types = event_type_service.get_all_event_types_for_org(region_record.org_id) + event_tags = event_tag_service.get_all_tags_for_org(region_record.org_id) + + form.set_options( + { + actions.CALENDAR_ADD_SERIES_AO: orm.as_selector_options( + names=[ao.name for ao in aos], + values=[str(ao.id) for ao in aos], + ), + actions.CALENDAR_ADD_EVENT_AO: orm.as_selector_options( + names=[ao.name for ao in aos], + values=[str(ao.id) for ao in aos], + ), + actions.CALENDAR_ADD_SERIES_LOCATION: orm.as_selector_options( + names=[get_location_display_name(loc) for loc in locations], + values=[str(loc.id) for loc in locations], + ), + actions.CALENDAR_ADD_SERIES_TYPE: orm.as_selector_options( + names=[et.name for et in event_types], + values=[str(et.id) for et in event_types], + ), + actions.CALENDAR_ADD_SERIES_TAG: orm.as_selector_options( + names=[tag.name for tag in event_tags], + values=[str(tag.id) for tag in event_tags], + ), + } + ) + + if edit_event: + form.delete_block(actions.CALENDAR_ADD_SERIES_DOW) + form.delete_block(actions.CALENDAR_ADD_SERIES_FREQUENCY) + form.delete_block(actions.CALENDAR_ADD_SERIES_INTERVAL) + form.delete_block(actions.CALENDAR_ADD_SERIES_INDEX) + form.delete_block(actions.CALENDAR_ADD_SERIES_START_DATE) + form.delete_block(actions.CALENDAR_ADD_SERIES_END_DATE) + initial_values = { + actions.CALENDAR_ADD_SERIES_NAME: edit_event.name, + actions.CALENDAR_ADD_SERIES_DESCRIPTION: edit_event.description, + actions.CALENDAR_ADD_SERIES_AO: str(edit_event.org_id), + actions.CALENDAR_ADD_EVENT_AO: str(edit_event.org_id), + actions.CALENDAR_ADD_SERIES_LOCATION: safe_convert(edit_event.location_id, str), + actions.CALENDAR_ADD_SERIES_TYPE: str(edit_event.event_type_ids[0]) if edit_event.event_type_ids else None, + actions.CALENDAR_ADD_SERIES_START_TIME: safe_convert(edit_event.start_time, lambda t: t[:2] + ":" + t[2:]), + actions.CALENDAR_ADD_SERIES_END_TIME: safe_convert(edit_event.end_time, lambda t: t[:2] + ":" + t[2:]), + } + + options = [] + if safe_get(edit_event, "is_private"): + options.append("private") + if safe_get(edit_event, "meta") and safe_get(edit_event.meta, META_EXCLUDE_FROM_PAX_VAULT): + options.append("exclude_from_pax_vault") + if safe_get(edit_event, "meta") and safe_get(edit_event.meta, META_DO_NOT_SEND_AUTO_PREBLASTS): + options.append("no_auto_preblasts") + if safe_get(edit_event, "highlight"): + options.append("highlight") + if options: + initial_values[actions.CALENDAR_ADD_SERIES_OPTIONS] = options + + # NOTE: event_tag_ids will always be empty when fetched from the API - + # the F3 Nation API does not return event tags for event (series) records. + if edit_event.event_tag_ids: + initial_values[actions.CALENDAR_ADD_SERIES_TAG] = [ + str(edit_event.event_tag_ids[0]) + ] # TODO: handle multiple event tags + else: + initial_values = { + actions.CALENDAR_ADD_SERIES_START_DATE: datetime.now().strftime("%Y-%m-%d"), + actions.CALENDAR_ADD_SERIES_FREQUENCY: Event_Cadence.weekly.name, + actions.CALENDAR_ADD_SERIES_INTERVAL: "1", + actions.CALENDAR_ADD_SERIES_INDEX: "1", + } + + # Triggered when the AO is selected - defaults are loaded for the location + action_id = safe_get(body, "actions", 0, "action_id") + if action_id in (actions.CALENDAR_ADD_SERIES_AO, actions.CALENDAR_ADD_EVENT_AO): + form_data = SERIES_FORM.get_selected_values(body) + ao = ao_service.get_ao_by_id(safe_convert(safe_get(form_data, action_id), int)) + if ao and ao.default_location_id: + initial_values[actions.CALENDAR_ADD_SERIES_LOCATION] = str(ao.default_location_id) + + form.set_initial_values(initial_values) + form.update_modal( + client=client, + view_id=safe_get(body, "view", "id"), + callback_id=actions.ADD_SERIES_CALLBACK_ID, + title_text=title_text, + parent_metadata=parent_metadata, + ) + elif update_view_id: + form.set_initial_values(initial_values) + form.update_modal( + client=client, + view_id=update_view_id, + callback_id=actions.ADD_SERIES_CALLBACK_ID, + title_text=title_text, + parent_metadata=parent_metadata, + ) + else: + form.set_initial_values(initial_values) + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text=title_text, + callback_id=actions.ADD_SERIES_CALLBACK_ID, + new_or_add="add", + parent_metadata=parent_metadata, + ) + + +def handle_series_add(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + metadata = _parse_view_private_metadata(body) + form_data = SERIES_FORM.get_selected_values(body) + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + end_date = safe_get(form_data, actions.CALENDAR_ADD_SERIES_END_DATE) # "YYYY-MM-DD" string or None + + start_time_str = safe_get(form_data, actions.CALENDAR_ADD_SERIES_START_TIME) + start_time = datetime.strptime(start_time_str, "%H:%M").strftime("%H%M") if start_time_str else None + + if safe_get(form_data, actions.CALENDAR_ADD_SERIES_END_TIME): + end_time: str = safe_get(form_data, actions.CALENDAR_ADD_SERIES_END_TIME).replace(":", "") + elif start_time_str: + end_time = (datetime.strptime(start_time_str, "%H:%M") + timedelta(hours=1)).strftime("%H%M") + else: + end_time = None + + # Slack won't return the selection for location and event type after being defaulted, so we need the initial value + view_blocks = safe_get(body, "view", "blocks") + location_block = [block for block in view_blocks if block["block_id"] == actions.CALENDAR_ADD_SERIES_LOCATION][0] + location_initial_value = safe_get(location_block, "element", "initial_option", "value") + location_id = form_data.get(actions.CALENDAR_ADD_SERIES_LOCATION) or location_initial_value + event_type_block = [block for block in view_blocks if block["block_id"] == actions.CALENDAR_ADD_SERIES_TYPE][0] + event_type_initial_value = safe_get(event_type_block, "element", "initial_option", "value") + event_type_id = form_data.get(actions.CALENDAR_ADD_SERIES_TYPE) or event_type_initial_value + + location_id = safe_convert(location_id, int) + event_type_id = safe_convert(event_type_id, int) + org_id = safe_convert( + safe_get(form_data, actions.CALENDAR_ADD_SERIES_AO) or safe_get(form_data, actions.CALENDAR_ADD_EVENT_AO), int + ) + event_tag_id = safe_convert(safe_get(form_data, actions.CALENDAR_ADD_SERIES_TAG, 0), int) + recurrence_interval = safe_convert(safe_get(form_data, actions.CALENDAR_ADD_SERIES_INTERVAL), int) + index_within_interval = safe_convert(safe_get(form_data, actions.CALENDAR_ADD_SERIES_INDEX), int) + + selected_options = safe_get(form_data, actions.CALENDAR_ADD_SERIES_OPTIONS) or [] + is_private = "private" in selected_options + exclude_from_pax_vault = "exclude_from_pax_vault" in selected_options + do_not_send_auto_preblasts = "no_auto_preblasts" in selected_options + highlight = "highlight" in selected_options + + series_service = _build_series_service() + ao_service = _build_ao_service() + event_type_service = _build_event_type_service() + + if safe_get(form_data, actions.CALENDAR_ADD_SERIES_NAME): + series_name = safe_get(form_data, actions.CALENDAR_ADD_SERIES_NAME) + else: + ao = ao_service.get_ao_by_id(org_id) + event_type = event_type_service.get_event_type_by_id(event_type_id) if event_type_id else None + series_name = f"{ao.name if ao else ''} {event_type.name if event_type else ''}".strip() + + day_of_weeks = safe_get(form_data, actions.CALENDAR_ADD_SERIES_DOW) + + if safe_get(metadata, "series_id"): + series_id = int(metadata["series_id"]) + existing_series = series_service.get_by_id(series_id) + merged_meta = dict(safe_get(existing_series, "meta") or {}) + if exclude_from_pax_vault: + merged_meta[META_EXCLUDE_FROM_PAX_VAULT] = True + else: + merged_meta.pop(META_EXCLUDE_FROM_PAX_VAULT, None) + if do_not_send_auto_preblasts: + merged_meta[META_DO_NOT_SEND_AUTO_PREBLASTS] = True + else: + merged_meta.pop(META_DO_NOT_SEND_AUTO_PREBLASTS, None) + + series_service.update_series( + series_id=series_id, + region_id=region_record.org_id, + ao_id=org_id, + name=series_name, + # start_date is not shown in the edit form; preserve from existing record + start_date=existing_series.start_date, + start_time=start_time, + end_time=end_time, + description=safe_get(form_data, actions.CALENDAR_ADD_SERIES_DESCRIPTION), + location_id=location_id, + # end_date is not shown in the edit form; preserve from existing record + end_date=existing_series.end_date, + event_type_ids=[event_type_id] if event_type_id else [], + event_tag_ids=[event_tag_id] if event_tag_id else [], + is_active=True, + is_private=is_private, + highlight=highlight, + meta=merged_meta or None, + ) + # The API cascade automatically updates all future EventInstances; no local update needed. + body["actions"] = [{"action_id": actions.CALENDAR_MANAGE_SERIES}] + build_series_list_form( + body, client, logger, context, region_record, update_view_id=safe_get(body, "view", "previous_view_id") + ) + trigger_map_revalidation(action="map.updated", map_update_data=MapUpdateData(eventId=metadata["series_id"])) + post_bot_log( + client=client, + region_record=region_record, + text=f":pencil2: Series edited: {series_name} by <@{slack_user_id or 'app'}>", + logger=logger, + ) + + else: + meta: dict = {} + if exclude_from_pax_vault: + meta[META_EXCLUDE_FROM_PAX_VAULT] = True + if do_not_send_auto_preblasts: + meta[META_DO_NOT_SEND_AUTO_PREBLASTS] = True + + created_series = [] + for dow in day_of_weeks: + created = series_service.create_series( + region_id=region_record.org_id, + ao_id=org_id, + name=series_name, + start_date=safe_get(form_data, actions.CALENDAR_ADD_SERIES_START_DATE), + start_time=start_time, + end_time=end_time, + day_of_week=dow, + description=safe_get(form_data, actions.CALENDAR_ADD_SERIES_DESCRIPTION), + location_id=location_id, + end_date=end_date, + recurrence_pattern=safe_get(form_data, actions.CALENDAR_ADD_SERIES_FREQUENCY), + recurrence_interval=recurrence_interval, + index_within_interval=index_within_interval, + event_type_ids=[event_type_id] if event_type_id else [], + event_tag_ids=[event_tag_id] if event_tag_id else [], + is_active=True, + is_private=is_private, + highlight=highlight, + meta=meta or None, + ) + created_series.append(created) + # The API cascade automatically creates all future EventInstances; no local create needed. + for record in created_series: + trigger_map_revalidation(action="map.created", map_update_data=MapUpdateData(eventId=record.id)) + post_bot_log( + client=client, + region_record=region_record, + text=f":heavy_plus_sign: Series created: {series_name} by <@{slack_user_id or 'app'}>", + logger=logger, + ) + + +def build_series_list_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + update_view_id=None, + loading_form: bool = False, +): + if loading_form: + update_view_id = add_loading_form(body, client, new_or_add="add") + + filter_org = region_record.org_id + filter_values = {} + if safe_get(body, "actions", 0, "action_id") in [ + actions.CALENDAR_MANAGE_SERIES_AO, + ]: + filter_values = orm.BlockView(blocks=copy.deepcopy(SERIES_LIST_FILTERS)).get_selected_values(body) + update_view_id = safe_get(body, "view", "id") + if safe_get(filter_values, actions.CALENDAR_MANAGE_SERIES_AO): + filter_org = safe_convert(safe_get(filter_values, actions.CALENDAR_MANAGE_SERIES_AO), int) + + title_text = "Delete or Edit a Series" + confirm_text = "Are you sure you want to edit / delete this series? This cannot be undone. Also, editing or deleting a series will also edit or delete all future events associated with the series." # noqa + + series_service = _build_series_service() + ao_service = _build_ao_service() + + if filter_org == region_record.org_id: + records = series_service.get_region_series(region_record.org_id) + else: + records = series_service.get_region_series(region_record.org_id, ao_id=filter_org) + + ao_orgs = ao_service.get_region_aos(region_record.org_id) + + form = orm.BlockView(blocks=copy.deepcopy(SERIES_LIST_FILTERS)) + form.set_options( + { + actions.CALENDAR_MANAGE_SERIES_AO: orm.as_selector_options( + names=[ao.name for ao in ao_orgs], + values=[str(ao.id) for ao in ao_orgs], + ), + } + ) + form.set_initial_values( + { + actions.CALENDAR_MANAGE_SERIES_AO: safe_get(filter_values, actions.CALENDAR_MANAGE_SERIES_AO), + } + ) + + for s in records: + label = f"{s.name} ({(s.day_of_week or '').capitalize()} @ {s.start_time})"[:50] + + form.blocks.append( + orm.SectionBlock( + label=label, + action=f"{actions.SERIES_EDIT_DELETE}_{s.id}", + element=orm.StaticSelectElement( + placeholder="Edit or Delete", + options=orm.as_selector_options(names=["Edit", "Delete"]), + confirm=orm.ConfirmObject( + title="Are you sure?", + text=confirm_text, + confirm="Yes, I'm sure", + deny="Whups, never mind", + ), + ), + ) + ) + if update_view_id: + form.update_modal( + client=client, + view_id=update_view_id, + callback_id=actions.EDIT_DELETE_SERIES_CALLBACK_ID, + title_text=title_text, + submit_button_text="None", + ) + + +def handle_series_edit_delete( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + series_id = safe_convert(safe_get(body, "actions", 0, "action_id").split("_")[1], int) + action = safe_get(body, "actions", 0, "selected_option", "value") + slack_user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") + + if action == "Edit": + series = _build_series_service().get_by_id(series_id) + build_series_add_form(body, client, logger, context, region_record, edit_event=series, loading_form=True) + elif action == "Delete": + series = _build_series_service().get_by_id(series_id) + _build_series_service().delete_series(series_id) + # The API cascade automatically soft-deletes all future EventInstances; no local update needed. + trigger_map_revalidation(action="map.deleted", map_update_data=MapUpdateData(eventId=series_id)) + body["view"]["private_metadata"] = json.dumps({"is_series": "True"}) + build_series_list_form( + body, client, logger, context, region_record, update_view_id=safe_get(body, "view", "id") + ) + post_bot_log( + client=client, + region_record=region_record, + text=f":wastebasket: Series deleted: {series.name if series else series_id} by <@{slack_user_id or 'app'}>", + logger=logger, + ) + + +SERIES_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="AO", + action=actions.CALENDAR_ADD_SERIES_AO, + element=orm.StaticSelectElement(placeholder="Select an AO"), + dispatch_action=True, + optional=False, + ), + orm.InputBlock( + label="Default Location", + action=actions.CALENDAR_ADD_SERIES_LOCATION, + element=orm.StaticSelectElement(placeholder="Select the default location"), + optional=True, + ), + orm.InputBlock( + label="Default Event Type", + action=actions.CALENDAR_ADD_SERIES_TYPE, + element=orm.StaticSelectElement(placeholder="Select the event type"), + optional=False, + ), + orm.InputBlock( + label="Default Event Tag", + action=actions.CALENDAR_ADD_SERIES_TAG, + element=orm.MultiStaticSelectElement(placeholder="Select the event tag", max_selected_items=1), + optional=True, + ), + orm.InputBlock( + label="Start Date", + action=actions.CALENDAR_ADD_SERIES_START_DATE, + element=orm.DatepickerElement(placeholder="Enter the start date"), + optional=False, + ), + orm.InputBlock( + label="End Date", + action=actions.CALENDAR_ADD_SERIES_END_DATE, + element=orm.DatepickerElement(placeholder="Enter the end date"), + hint="If no end date is provided, the series will continue indefinitely.", + ), + orm.InputBlock( + label="Start Time", + action=actions.CALENDAR_ADD_SERIES_START_TIME, + element=orm.TimepickerElement(placeholder="Enter the start time"), + optional=False, + ), + orm.InputBlock( + label="End Time", + action=actions.CALENDAR_ADD_SERIES_END_TIME, + element=orm.TimepickerElement(placeholder="Enter the end time"), + hint="If no end time is provided, the event will be defaulted to be one hour long.", + ), + orm.InputBlock( + label="Day(s) of the Week", + action=actions.CALENDAR_ADD_SERIES_DOW, + element=orm.CheckboxInputElement( + options=orm.as_selector_options( + names=[d.name.capitalize() for d in Day_Of_Week], + values=[d.name for d in Day_Of_Week], + ), + ), + optional=False, + ), + orm.InputBlock( + "Interval", + action=actions.CALENDAR_ADD_SERIES_INTERVAL, + element=orm.StaticSelectElement( + placeholder="Select the interval", + options=orm.as_selector_options(**constants.INTERVAL_OPTIONS), + initial_value="1", + ), + optional=False, + ), + orm.InputBlock( + label="Series Frequency", + action=actions.CALENDAR_ADD_SERIES_FREQUENCY, + element=orm.StaticSelectElement( + placeholder="Select the frequency", + options=orm.as_selector_options(names=["Week", "Month"], values=[p.name for p in Event_Cadence]), + initial_value=Event_Cadence.weekly.name, + ), + optional=False, + ), + orm.InputBlock( + label="Which week of the month?", + action=actions.CALENDAR_ADD_SERIES_INDEX, + element=orm.StaticSelectElement( + placeholder="Select the week", + options=orm.as_selector_options(**constants.WEEK_INDEX_OPTIONS), + initial_value="1", + ), + optional=False, + hint="Only relevant if Month is selected above. Select 'Last' for the last occurrence of the day in the month.", # noqa + ), + orm.InputBlock( + label="Series Name", + action=actions.CALENDAR_ADD_SERIES_NAME, + element=orm.PlainTextInputElement(placeholder="Enter the series name"), + hint="If left blank, will default to the AO name + event type.", + ), + orm.InputBlock( + label="Description", + action=actions.CALENDAR_ADD_SERIES_DESCRIPTION, + element=orm.PlainTextInputElement( + placeholder="Enter a description", + multiline=True, + ), + optional=True, + ), + orm.InputBlock( + label="Options", + action=actions.CALENDAR_ADD_SERIES_OPTIONS, + element=orm.CheckboxInputElement( + options=orm.as_selector_options( + names=[ + "Make event private", + "Exclude stats from PAX Vault", + "Do not send auto-preblasts", + "Highlight on Special Events List", + ], + values=[ + "private", + "exclude_from_pax_vault", + "no_auto_preblasts", + "highlight", + ], + descriptions=[ + "Hides series from Maps and Region Pages.", + "Can still be queried from BigQuery or custom dashboards.", + "Opts this series out of automated preblasts.", + "Shown in the calendar image channel if enabled.", + ], + ), + ), + optional=True, + ), + ] +) + +SERIES_LIST_FILTERS = [ + orm.InputBlock( + label="AO Filter", + action=actions.CALENDAR_MANAGE_SERIES_AO, + element=orm.StaticSelectElement( + placeholder="Select an AO", + ), + optional=True, + dispatch_action=True, + ), +] diff --git a/apps/slackbot/features/canvas.py b/apps/slackbot/features/canvas.py new file mode 100644 index 00000000..f3dc831f --- /dev/null +++ b/apps/slackbot/features/canvas.py @@ -0,0 +1,126 @@ +import ssl +from datetime import timedelta +from logging import Logger +from typing import List + +from f3_data_models.models import EventInstance, Org, Org_Type +from f3_data_models.utils import DbManager +from slack_sdk import WebClient +from sqlalchemy import or_ + +from utilities.constants import GCP_IMAGE_URL +from utilities.database.orm import SlackSettings +from utilities.database.special_queries import get_position_users +from utilities.helper_functions import current_date_cst, safe_get + + +def create_special_events_blocks(events: List[EventInstance], slack_settings: SlackSettings) -> str: + text = "" + for i, event in enumerate(events): + text += f"{i + 1}. **{event.name}** - {event.start_date.strftime('%A, %B %d')} - {event.start_time} @ {event.org.name}\n" # noqa + + if event.preblast_ts: + # TODO: need to make this work for region-level events + if slack_settings.default_preblast_destination == "specified_channel": + channel_id = slack_settings.preblast_destination_channel + else: + channel_id = event.org.meta.get("slack_channel_id") + + if channel_id: + text += f"\n" # noqa + + return text + + +def update_canvas(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + # show calendar image + msg = "# :calendar: This Week\n\n" + if region_record.calendar_image_current: + msg += "![This Week](" + msg += GCP_IMAGE_URL.format( + bucket="f3nation-calendar-images", + image_name=region_record.calendar_image_current, + ) + msg += ")\n\n" + + # list special events + special_events: List[EventInstance] = DbManager.find_records( + cls=EventInstance, + filters=[ + or_( + EventInstance.org_id == region_record.org_id, + EventInstance.org.has(Org.parent_id == region_record.org_id), + ), + EventInstance.start_date >= current_date_cst(), + EventInstance.start_date + <= current_date_cst() + timedelta(days=region_record.special_events_post_days or 7), + EventInstance.is_active, + EventInstance.highlight, + ], + joinedloads=[EventInstance.org], + ) + if len(special_events) > 0: + msg += "# :tada: Special Events:\n\n" + msg += create_special_events_blocks(special_events, region_record) + msg += "\n" + + # list SLT members + position_users = get_position_users(region_record.org_id, region_record.org_id, slack_team_id=region_record.team_id) + if len(position_users) > 0: + msg += "# :busts_in_silhouette: Shared Leadership Team\n\n" + for user in position_users: + slack_user_id = None + for slack_user in user.slack_users: + if slack_user: + slack_user_id = slack_user.slack_id if slack_user.slack_team_id == region_record.team_id else None + if slack_user_id: + msg += f"**{user.position.name}**\n\n![](@{slack_user_id})\n\n" + + msg += "This canvas is automatically updated every 24 hours. Any manual edits will be overwritten." + + # post to canvas + if region_record.canvas_channel: + channel_info = client.conversations_info(channel=region_record.canvas_channel) + channel_tabs = safe_get(channel_info, "channel", "properties", "tabs") or [] + canvas_tab = next((tab for tab in channel_tabs if tab.get("type") == "canvas"), None) + canvas_id = safe_get(canvas_tab, "data", "file_id") + + if canvas_id: + client.canvases_edit( + canvas_id=canvas_id, + changes=[ + { + "operation": "replace", + "document_content": {"type": "markdown", "markdown": msg}, + } + ], + ) + else: + client.conversations_canvases_create( + channel_id=region_record.canvas_channel, + document_content={"type": "markdown", "markdown": msg}, + title="Region Canvas", + ) + + +def update_all_canvases(): + regions: List[Org] = DbManager.find_records( + cls=Org, filters=[Org.org_type == Org_Type.region], joinedloads=[Org.slack_space] + ) + regions = [region for region in regions if region.slack_space and region.slack_space.settings] + + for region in regions: + slack_settings = SlackSettings(**region.slack_space.settings) + if slack_settings.canvas_channel and slack_settings.special_events_enabled: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + client = WebClient(token=slack_settings.bot_token, ssl=ssl_context) + try: + update_canvas({}, client, Logger("logger"), {}, slack_settings) + except Exception as e: + Logger("logger").error(f"Error updating canvas for region {region.name}: {e}") + + +if __name__ == "__main__": + update_all_canvases() diff --git a/apps/slackbot/features/config.py b/apps/slackbot/features/config.py new file mode 100644 index 00000000..a5d537bb --- /dev/null +++ b/apps/slackbot/features/config.py @@ -0,0 +1,220 @@ +import copy +import os +from logging import Logger + +from cryptography.fernet import Fernet +from f3_data_models.models import SlackSpace +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient + +from features import db_admin +from utilities import constants +from utilities.constants import ALL_USERS_ARE_ADMINS +from utilities.database.orm import SlackSettings +from utilities.database.special_queries import get_admin_users, make_user_admin +from utilities.helper_functions import ( + get_user, + safe_convert, + safe_get, + update_local_region_records, +) +from utilities.slack import actions, forms + + +def build_config_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + user_id = safe_get(body, "user_id") or safe_get(body, "user", "id") + update_view_id = safe_get(body, actions.LOADING_ID) + if body.get("text") == os.environ.get("DB_ADMIN_PASSWORD"): + db_admin.build_db_admin_form(body, client, logger, context, region_record, update_view_id) + else: + if ALL_USERS_ARE_ADMINS: + user_is_admin = True + else: + slack_user = get_user(user_id, region_record, client, logger) + # user_permissions = [p.name for p in get_user_permission_list(slack_user.user_id, region_record.org_id)] + # user_is_admin = constants.PERMISSIONS[constants.ALL_PERMISSIONS] in user_permissions + admin_users = get_admin_users(region_record.org_id, region_record.team_id) + user_is_admin = any(u[0].id == slack_user.user_id for u in admin_users) + + if user_is_admin: + config_form = copy.deepcopy(forms.CONFIG_FORM) + if region_record.org_id in constants.ACHIEVEMENTS_ALPHA_TESTING_ORG_IDS: + config_form.blocks[0].elements.append(copy.deepcopy(forms.ACHIEVEMENT_BUTTON)) + else: + if region_record.org_id is None: + config_form = copy.deepcopy(forms.CONFIG_NO_ORG_FORM) + elif len(admin_users) == 0: + make_user_admin(region_record.org_id, slack_user.user_id) + config_form = copy.deepcopy(forms.CONFIG_FORM) + else: + config_form = copy.deepcopy(forms.CONFIG_NO_PERMISSIONS_FORM) + config_form.blocks[1].label += " Your region's admin users are: " + user_labels = [] + for admin_user in admin_users: + if admin_user[1]: + user_labels.append(f" <@{admin_user[1].slack_id}>") + else: + user_labels.append(admin_user[0].f3_name or "") + config_form.blocks[1].label += ", ".join(user_labels) + + config_form.update_modal( + client=client, + view_id=update_view_id, + callback_id=actions.CONFIG_CALLBACK_ID, + title_text="F3 Nation Settings", + submit_button_text="None", + ) + + +def build_config_email_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + config_form = copy.deepcopy(forms.CONFIG_EMAIL_FORM) + + if region_record.email_password: + fernet = Fernet(os.environ[constants.PASSWORD_ENCRYPT_KEY].encode()) + email_password_decrypted = fernet.decrypt(region_record.email_password.encode()).decode() + else: + email_password_decrypted = "SamplePassword123!" + + config_form.set_initial_values( + { + actions.CONFIG_EMAIL_ENABLE: "enable" if region_record.email_enabled == 1 else "disable", + actions.CONFIG_EMAIL_SHOW_OPTION: "yes" if region_record.email_option_show == 1 else "no", + actions.CONFIG_EMAIL_FROM: region_record.email_user or "example_sender@gmail.com", + actions.CONFIG_EMAIL_TO: region_record.email_to or "example_destination@gmail.com", + actions.CONFIG_EMAIL_SERVER: region_record.email_server or "smtp.gmail.com", + actions.CONFIG_EMAIL_PORT: str(region_record.email_server_port or 587), + actions.CONFIG_EMAIL_PASSWORD: email_password_decrypted, + actions.CONFIG_POSTIE_ENABLE: "yes" if region_record.postie_format == 1 else "no", + } + ) + + config_form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + callback_id=actions.CONFIG_EMAIL_CALLBACK_ID, + title_text="Email Settings", + new_or_add="add", + ) + + +def build_config_general_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + config_form = copy.deepcopy(forms.CONFIG_GENERAL_FORM) + + config_form.set_initial_values( + { + actions.CONFIG_EDITING_LOCKED: "yes" if region_record.editing_locked == 1 else "no", + actions.CONFIG_DEFAULT_DESTINATION: region_record.default_backblast_destination + or constants.CONFIG_DESTINATION_AO["value"], + actions.CONFIG_DESTINATION_CHANNEL: region_record.backblast_destination_channel, + actions.CONFIG_DEFAULT_PREBLAST_DESTINATION: region_record.default_preblast_destination + or constants.CONFIG_DESTINATION_AO["value"], + actions.CONFIG_PREBLAST_DESTINATION_CHANNEL: region_record.preblast_destination_channel, + actions.CONFIG_BACKBLAST_MOLESKINE_TEMPLATE: region_record.backblast_moleskin_template + or constants.DEFAULT_BACKBLAST_MOLESKINE_TEMPLATE, + actions.CONFIG_PREBLAST_MOLESKINE_TEMPLATE: region_record.preblast_moleskin_template + or constants.DEFAULT_PREBLAST_MOLESKINE_TEMPLATE, + actions.CONFIG_ENABLE_STRAVA: "enable" if region_record.strava_enabled == 1 else "disable", + actions.CONFIG_PREBLAST_REMINDER_DAYS: region_record.preblast_reminder_days, + actions.CONFIG_BACKBLAST_REMINDER_DAYS: region_record.backblast_reminder_days, + actions.CONFIG_AUTOMATED_PREBLAST: region_record.automated_preblast_option or "q_only", + actions.CONFIG_AUTOMATED_PREBLAST_TIME: f"{str(region_record.automated_preblast_hour_cst).zfill(2)}:00" + if region_record.automated_preblast_hour_cst is not None + else "12:00", + actions.CONFIG_SCHEDULED_PREBLAST_TIME: f"{str(region_record.scheduled_preblast_hour_cst).zfill(2)}:00" + if region_record.scheduled_preblast_hour_cst is not None + else "12:00", + actions.CONFIG_PREBLAST_REMINDER_TIME: f"{str(region_record.preblast_reminder_hour_cst).zfill(2)}:00" + if region_record.preblast_reminder_hour_cst is not None + else "10:00", + actions.CONFIG_HC_ANNOUNCE_OPTION: region_record.hc_announce_option or "off", + actions.CONFIG_HC_ANNOUNCE_TARGETS: region_record.hc_announce_targets or "both", + actions.CONFIG_BOT_LOG_CHANNEL: region_record.bot_log_channel, + } + ) + + config_form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + callback_id=actions.CONFIG_GENERAL_CALLBACK_ID, + title_text="General Settings", + new_or_add="add", + ) + + +def handle_config_email_post( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + config_data = forms.CONFIG_EMAIL_FORM.get_selected_values(body) + + if safe_get(config_data, actions.CONFIG_EMAIL_ENABLE) == "enable": + fernet = Fernet(os.environ[constants.PASSWORD_ENCRYPT_KEY].encode()) + email_password_decrypted = safe_get(config_data, actions.CONFIG_EMAIL_PASSWORD) + if email_password_decrypted: + email_password_encrypted = fernet.encrypt( + safe_get(config_data, actions.CONFIG_EMAIL_PASSWORD).encode() + ).decode() + else: + email_password_encrypted = None + else: + email_password_encrypted = None + + region_record.email_enabled = 1 if safe_get(config_data, actions.CONFIG_EMAIL_ENABLE) == "enable" else 0 + region_record.email_option_show = 1 if safe_get(config_data, actions.CONFIG_EMAIL_SHOW_OPTION) == "yes" else 0 + region_record.email_server = safe_get(config_data, actions.CONFIG_EMAIL_SERVER) + region_record.email_server_port = safe_convert(safe_get(config_data, actions.CONFIG_EMAIL_PORT), int) + region_record.email_user = safe_get(config_data, actions.CONFIG_EMAIL_FROM) + region_record.email_to = safe_get(config_data, actions.CONFIG_EMAIL_TO) + region_record.email_password = email_password_encrypted + region_record.postie_format = 1 if safe_get(config_data, actions.CONFIG_POSTIE_ENABLE) == "yes" else 0 + + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + + update_local_region_records() + + +def handle_config_general_post( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + config_data = forms.CONFIG_GENERAL_FORM.get_selected_values(body) + + region_record.editing_locked = 1 if safe_get(config_data, actions.CONFIG_EDITING_LOCKED) == "yes" else 0 + region_record.default_backblast_destination = safe_get(config_data, actions.CONFIG_DEFAULT_DESTINATION) + region_record.backblast_destination_channel = safe_get(config_data, actions.CONFIG_DESTINATION_CHANNEL) + region_record.default_preblast_destination = safe_get(config_data, actions.CONFIG_DEFAULT_PREBLAST_DESTINATION) + region_record.preblast_destination_channel = safe_get(config_data, actions.CONFIG_PREBLAST_DESTINATION_CHANNEL) + region_record.backblast_moleskin_template = safe_get(config_data, actions.CONFIG_BACKBLAST_MOLESKINE_TEMPLATE) + region_record.preblast_moleskin_template = safe_get(config_data, actions.CONFIG_PREBLAST_MOLESKINE_TEMPLATE) + region_record.strava_enabled = 1 if safe_get(config_data, actions.CONFIG_ENABLE_STRAVA) == "enable" else 0 + region_record.preblast_reminder_days = safe_convert( + safe_get(config_data, actions.CONFIG_PREBLAST_REMINDER_DAYS), int + ) + region_record.backblast_reminder_days = safe_convert( + safe_get(config_data, actions.CONFIG_BACKBLAST_REMINDER_DAYS), int + ) + region_record.automated_preblast_option = safe_get(config_data, actions.CONFIG_AUTOMATED_PREBLAST) or "q_only" + region_record.automated_preblast_hour_cst = safe_convert( + (safe_get(config_data, actions.CONFIG_AUTOMATED_PREBLAST_TIME) or "12:00").split(":")[0], int + ) + region_record.scheduled_preblast_hour_cst = safe_convert( + (safe_get(config_data, actions.CONFIG_SCHEDULED_PREBLAST_TIME) or "12:00").split(":")[0], int + ) + region_record.preblast_reminder_hour_cst = safe_convert( + (safe_get(config_data, actions.CONFIG_PREBLAST_REMINDER_TIME) or "10:00").split(":")[0], int + ) + region_record.hc_announce_option = safe_get(config_data, actions.CONFIG_HC_ANNOUNCE_OPTION) or "off" + region_record.hc_announce_targets = safe_get(config_data, actions.CONFIG_HC_ANNOUNCE_TARGETS) or "both" + region_record.bot_log_channel = safe_get(config_data, actions.CONFIG_BOT_LOG_CHANNEL) + + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + + update_local_region_records() diff --git a/apps/slackbot/features/connect.py b/apps/slackbot/features/connect.py new file mode 100644 index 00000000..f2d17ee8 --- /dev/null +++ b/apps/slackbot/features/connect.py @@ -0,0 +1,402 @@ +import os +import ssl +from logging import Logger + +from f3_data_models.models import Org, Org_x_SlackSpace, Role, Role_x_User_x_Org, SlackSpace +from f3_data_models.utils import DbManager +from slack_sdk.models.blocks import ( + ActionsBlock, + HeaderBlock, + InputBlock, + RichTextBlock, + RichTextListElement, + RichTextSectionElement, + SectionBlock, +) +from slack_sdk.models.blocks.block_elements import ( + ButtonElement, + DatePickerElement, + ExternalDataSelectElement, + PlainTextInputElement, +) +from slack_sdk.models.views import View, ViewState +from slack_sdk.web import WebClient + +# from features.calendar.series import create_events +from utilities.database.orm import SlackSettings +from utilities.helper_functions import ( + get_region_record, + get_user, + safe_get, + update_local_region_records, +) +from utilities.slack.actions import LOADING_ID + +CONNECT_EXISTING_REGION = "connect_existing_region" +CREATE_NEW_REGION = "create_new_region" +CONNECT_EXISTING_REGION_CALLBACK_ID = "connect_existing_region" +CREATE_NEW_REGION_CALLBACK_ID = "create_new_region" +SELECT_REGION = "select_region" +NEW_REGION_NAME = "new_region_name" +STARFISH_EXISTING_REGION = "connect_region_starfish" +SEARCH_REGION = "search_region" +MIGRATION_DATE = "connect_migration_date" +APPROVE_CONNECTION = "approve_connection" +DENY_CONNECTION = "deny_connection" + + +def build_connect_options_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + # Create the form + # should start with text saying "Connect your workspace to a F3 region" + # then two buttons: "Connect to an existing region" and "Create a new region" + form: View = View( + type="modal", + title="Connect your workspace", + blocks=[ + SectionBlock( + text="This Slack workspace is not currently connected to a F3 region. Please select an option below to request a connection.", # noqa + ), + ActionsBlock( + elements=[ + ButtonElement( + text="Connect to an existing region", + action_id=CONNECT_EXISTING_REGION, + ), + # ButtonElement( + # text="Create a new region", + # action_id=CREATE_NEW_REGION, + # ), + # ButtonElement( + # text="Starfish from an exisiting region", + # action_id=STARFISH_EXISTING_REGION, + # ), + ] + ), + ], + ) + if safe_get(body, LOADING_ID): + client.views_update(view_id=safe_get(body, LOADING_ID), view=form) + else: + client.views_push(trigger_id=safe_get(body, "trigger_id"), view=form) + # client.views_push(interactivity_pointer=safe_get(body, "trigger_id"), view=form) + + +def handle_connect_options(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + action_id = safe_get(body, "actions", 0, "action_id") + if action_id == CONNECT_EXISTING_REGION: + build_existing_region_form(body, client, logger, context) + elif action_id == CREATE_NEW_REGION: + build_new_region_form(body, client, logger, context) + elif not action_id: + if safe_get(body, "view", "callback_id") == CONNECT_EXISTING_REGION_CALLBACK_ID: + handle_existing_region_selection(body, client, logger, context) + else: + build_connect_options_form(body, client, logger, context) + elif action_id == SEARCH_REGION: + build_existing_region_form(body, client, logger, context) + + +def build_existing_region_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + form: View = View( + type="modal", + callback_id=CONNECT_EXISTING_REGION_CALLBACK_ID, + title="Connect existing region", + blocks=[ + InputBlock( + label="Search for a region", + block_id=SELECT_REGION, + element=ExternalDataSelectElement( + action_id=SELECT_REGION, + placeholder="Start typing to search...", + min_query_length=3, + ), + optional=False, + ), + InputBlock( + label="Start Date", + block_id=MIGRATION_DATE, + element=DatePickerElement( + action_id=MIGRATION_DATE, + placeholder="Select a start date", + ), + optional=False, + hint="Event instances will be created for this day forward in the system.", # noqa + ), + SectionBlock( + text="If your region is not appearing in the search, it's likely because it hasn't been set up yet. Please submit a new region request at https://f3nation.com/start-region. Once your Expansion Q has reached out and approved your request, you can come back here to connect the app.", # noqa + ), + ], + submit="Request Connection", + ) + + client.views_update(view_id=safe_get(body, "view", "id"), view=form) + + +def build_new_region_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + form: View = View( + type="modal", + callback_id=CREATE_NEW_REGION_CALLBACK_ID, + title="Create a new region", + blocks=[ + InputBlock( + label="Region Name", + block_id=NEW_REGION_NAME, + element=PlainTextInputElement( + placeholder="Enter the region name", + action_id=NEW_REGION_NAME, + ), + ), + ], + submit="Request Creation", + ) + client.views_update(view_id=safe_get(body, "view", "id"), view=form) + + +def handle_existing_region_selection( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + state = ViewState(**safe_get(body, "view", "state")) + region_select = state.values.get(SELECT_REGION).get(SELECT_REGION) + date_select = state.values.get(MIGRATION_DATE).get(MIGRATION_DATE) + user_info = client.users_info(user=safe_get(body, "user", "id")) + user_name = safe_get(user_info, "user", "profile", "display_name") or safe_get(user_info, "user", "name") + team_id = safe_get(body, "team", "id") + region_record = region_record or get_region_record(team_id, body, context, client, logger) + blocks = [ + HeaderBlock(text="Region Connection Request"), + RichTextBlock( + elements=[ + RichTextListElement( + style="bullet", + indent=0, + elements=[ + RichTextSectionElement( + elements=[ + { + "type": "text", + "text": f"Region: {region_select.selected_option.get('text').get('text')}", + } + ] + ), + RichTextSectionElement( + elements=[ + { + "type": "text", + "text": f"Workspace Domain: {safe_get(body, 'team', 'domain')}", + } + ] + ), + RichTextSectionElement( + elements=[ + { + "type": "text", + "text": f"Requestor: {user_name}", + } + ] + ), + RichTextSectionElement( + elements=[ + { + "type": "text", + "text": f"PAXMiner Region: {region_record.paxminer_schema or 'No PAXMiner connection'}", # noqa + } + ] + ), + RichTextSectionElement( + elements=[ + { + "type": "text", + "text": f"Selected Migration Date: {date_select.selected_date}", + } + ] + ), + ], + ), + ] + ), + ActionsBlock( + elements=[ + ButtonElement( + text="Approve", + action_id=APPROVE_CONNECTION, + value=region_select.selected_option.get("value"), + style="primary", + ), + ButtonElement( + text="Deny", + action_id=DENY_CONNECTION, + value=region_select.selected_option.get("value"), + style="danger", + ), + ] + ), + ] + metadata = { + "event_type": "region_connection_request", + "event_payload": { + "user_id": safe_get(body, "user", "id"), + "requestor_bot_token": region_record.bot_token, + "region_id": region_select.selected_option.get("value"), + "region_name": region_select.selected_option.get("text").get("text"), + "migration_date": date_select.selected_date, + "team_id": team_id, + }, + } # noqa + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + if os.environ.get("ADMIN_BOT_TOKEN") and os.environ.get("ADMIN_CHANNEL_ID"): + try: + send_client = WebClient(token=os.environ.get("ADMIN_BOT_TOKEN"), ssl=ssl_context) + send_client.chat_postMessage( + channel=os.environ.get("ADMIN_CHANNEL_ID"), + text="Region Connection Request", + blocks=blocks, + metadata=metadata, + ) + except Exception as e: + logger.error(f"Error sending region connection request: {e}") + + +def handle_new_region_creation( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + state = ViewState(**safe_get(body, "view", "state")) + region_name = state.values.get(NEW_REGION_NAME).get(NEW_REGION_NAME) + print(f"Creating new region with name {region_name.value}") + + +def handle_approve_connection( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + metadata = safe_get(body, "message", "metadata") or {} + metadata = metadata.get("event_payload") or {} + team_id = metadata.get("team_id") + org_record = DbManager.get(Org, metadata.get("region_id")) + + # Connect the slack space to the new org + if team_id: + slack_space_record = DbManager.find_first_record(SlackSpace, [SlackSpace.team_id == team_id]) + region_record = get_region_record(team_id, body, context, client, logger) + if slack_space_record: + connect_record = Org_x_SlackSpace( + org_id=org_record.id, + slack_space_id=slack_space_record.id, + ) + DbManager.create_record(connect_record) + # Update the slack space record with the new org + region_record.org_id = org_record.id + region_record.migration_date = metadata.get("migration_date") + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + # Make the current user an admin of the new org + slack_user_id = metadata.get("user_id") + user_id = get_user(slack_user_id, region_record, client, logger).user_id + admin_role_id = DbManager.find_first_record(Role, filters=[Role.name == "admin"]).id + try: + DbManager.create_record( + Role_x_User_x_Org( + user_id=user_id, + org_id=org_record.id, + role_id=admin_role_id, + ) + ) + except Exception: + logger.info("Requestor is already an admin of this org, skipping creation of admin role.") + # Create events from the migration date forward + # event_records = DbManager.find_records( + # Event, + # filters=[ + # Event.is_active, + # or_(Event.org_id == region_record.org_id, Event.org.has(Org.parent_id == region_record.org_id)), + # or_(Event.end_date >= current_date_cst(), Event.end_date.is_(None)), + # ], + # joinedloads="all", + # ) + # start_date = ( + # safe_convert(metadata.get("migration_date"), datetime.strptime, args=["%Y-%m-%d"]) or datetime.now() + # ).date() + # create_events(event_records, clear_first=True, start_date=start_date) + + blocks = [ + HeaderBlock(text="Region Connection Request Approved"), + SectionBlock( + text=f"Your region connection request was approved by the F3 Nation Admins! Your slack space is now connected to {metadata.get('region_name')}. Events have been created starting on {metadata.get('migration_date')}, and your PAX can start signing up to Q through the `/f3-calendar` command." # noqa + ), + ] + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + if metadata.get("user_id") and metadata.get("requestor_bot_token"): + try: + send_client = WebClient(token=metadata.get("requestor_bot_token"), ssl=ssl_context) + send_client.chat_postMessage( + channel=metadata.get("user_id"), + text="Region Connection Request Approved", + blocks=blocks, + ) + except Exception as e: + logger.error(f"Error sending region connection approval: {e}") + + # Update the original admin message to remove action buttons and indicate completion + try: + channel_id = ( + safe_get(body, "container", "channel_id") + or safe_get(body, "channel", "id") + or safe_get(body, "channel", "channel_id") + ) + message_ts = safe_get(body, "container", "message_ts") or safe_get(body, "message", "ts") + + if channel_id and message_ts and os.environ.get("ADMIN_BOT_TOKEN"): + admin_blocks = [ + HeaderBlock(text="Region Connection Request - Approved"), + SectionBlock( + text=( + f"Approved by <@{safe_get(body, 'user', 'id')}>. " + f"This request is complete. Region: {metadata.get('region_name')} | " + f"Migration Date: {metadata.get('migration_date')}" + ) + ), + ] + admin_client = WebClient(token=os.environ.get("ADMIN_BOT_TOKEN"), ssl=ssl_context) + admin_client.chat_update( + channel=channel_id, + ts=message_ts, + text="Region Connection Request - Approved", + blocks=admin_blocks, + ) + except Exception as e: + logger.error(f"Error updating original admin message: {e}") + + update_local_region_records() + + +def handle_deny_connection(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + metadata = safe_get(body, "message", "metadata") or {} + metadata = metadata.get("event_payload") or {} + blocks = [ + HeaderBlock(text="Region Connection Request Denied"), + SectionBlock( + text="Your region connection request was denied by the F3 Nation Admins. Please reach out to it@f3nation.com for more information." # noqa + ), + ] + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + if metadata.get("user_id") and metadata.get("requestor_bot_token"): + try: + send_client = WebClient(token=metadata.get("requestor_bot_token"), ssl=ssl_context) + send_client.chat_postMessage( + channel=metadata.get("user_id"), + text="Region Connection Request Denied", + blocks=blocks, + ) + except Exception as e: + logger.error(f"Error sending region connection denial: {e}") diff --git a/apps/slackbot/features/custom_fields.py b/apps/slackbot/features/custom_fields.py new file mode 100644 index 00000000..fafb8ea9 --- /dev/null +++ b/apps/slackbot/features/custom_fields.py @@ -0,0 +1,256 @@ +import copy +from logging import Logger + +from f3_data_models.models import SlackSpace +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient + +from utilities.database.orm import SlackSettings +from utilities.helper_functions import ( + safe_get, + update_local_region_records, +) +from utilities.slack import actions, forms +from utilities.slack import orm as slack_orm + + +def build_custom_field_menu( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + update_view_id: str = None, +) -> None: + """Iterates through the custom fields and builds a menu to enable/disable and add/edit/delete them. + + Args: + client (WebClient): Slack webclient + region_record (Region): Region record + trigger_id (str): The event's trigger id + callback_id (str): The event's callback id + """ + trigger_id = safe_get(body, "trigger_id") + + blocks = [] + custom_fields = region_record.custom_fields or {} + # if region_record.custom_fields is None: + # custom_fields = { + # "Event Type": { + # "name": "Event Type", + # "type": "Dropdown", + # "options": ["Bootcamp", "QSource", "Rucking", "2nd F"], + # "enabled": False, + # } + # } + # region_record.custom_fields = custom_fields + # DbManager.update_records( + # cls=SlackSpace, + # filters=[SlackSpace.team_id == region_record.team_id], + # fields={SlackSpace.settings: region_record.__dict__}, + # ) + # update_local_region_records() + + for custom_field in custom_fields.values(): + label = f"Name: {custom_field['name']}\nType: {custom_field['type']}" + if custom_field["type"] == "Dropdown": + label += f"\nOptions: {', '.join(custom_field['options'])}" + + blocks.extend( + [ + slack_orm.InputBlock( + element=slack_orm.RadioButtonsElement( + options=slack_orm.as_selector_options( + names=["Enabled", "Disabled"], + values=["enable", "disable"], + ), + initial_value="enable" if custom_field["enabled"] else "disable", + ), + action=f"{actions.CUSTOM_FIELD_ENABLE}_{custom_field['name']}", + label=label, + optional=False, + ), + slack_orm.ActionsBlock( + elements=[ + slack_orm.ButtonElement( + label="Edit field", + action=actions.CUSTOM_FIELD_EDIT, + value=custom_field["name"], + ), + slack_orm.ButtonElement( + label="Delete field", + action=actions.CUSTOM_FIELD_DELETE, + value=custom_field["name"], + ), + ], + ), + slack_orm.DividerBlock(), + ] + ) + + blocks.append( + slack_orm.ActionsBlock( + elements=[ + slack_orm.ButtonElement( + label="New custom field", + action=actions.CUSTOM_FIELD_ADD, + ), + ], + ) + ) + view = slack_orm.BlockView(blocks=blocks) + if update_view_id: + view.update_modal( + client=client, + view_id=update_view_id, + callback_id=actions.CUSTOM_FIELD_MENU_CALLBACK_ID, + title_text="Custom Backblast fields", + ) + else: + view.post_modal( + client=client, + trigger_id=trigger_id, + callback_id=actions.CUSTOM_FIELD_MENU_CALLBACK_ID, + title_text="Custom Backblast fields", + new_or_add="add", + ) + + +def build_custom_field_add_edit( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +) -> None: + """Builds a form to add or edit a custom field. + + Args: + client (WebClient): Slack webclient + region_record (Region): Region record + trigger_id (str): The event's trigger id + callback_id (str): The event's callback id + custom_field_name (str): The name of the custom field to edit + """ + trigger_id = safe_get(body, "trigger_id") + if safe_get(body, "actions", 0, "action_id") == actions.CUSTOM_FIELD_EDIT: + custom_field_name = safe_get(body, "actions", 0, "value") + else: + custom_field_name = None + + custom_field_form = copy.deepcopy(forms.CUSTOM_FIELD_ADD_EDIT_FORM) + custom_field = safe_get(region_record.custom_fields, custom_field_name or "") + + if custom_field: + custom_field_form.set_initial_values( + { + actions.CUSTOM_FIELD_ADD_NAME: custom_field["name"], + actions.CUSTOM_FIELD_ADD_TYPE: custom_field["type"], + actions.CUSTOM_FIELD_ADD_OPTIONS: ( + ",".join(custom_field["options"]) if custom_field["type"] == "Dropdown" else " " + ), + } + ) + action_text = "Edit" + else: + action_text = "Add" + + custom_field_form.post_modal( + client=client, + trigger_id=trigger_id, + callback_id=actions.CUSTOM_FIELD_ADD_CALLBACK_ID, + title_text=f"{action_text} custom field", + submit_button_text=f"{action_text} field", + notify_on_close=True, + new_or_add="add", + ) + + +def handle_custom_field_delete( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + trigger_id: str, +): + custom_fields: dict = region_record.custom_fields or {} + custom_fields.pop(safe_get(body, "actions", 0, "value")) + build_custom_field_menu( + body=body, + client=client, + logger=logger, + context=context, + region_record=region_record, + update_view_id=safe_get(body, "view", "id"), + ) + + +def delete_custom_field(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + custom_field_name = safe_get(body, "actions", 0, "value") + view_id = safe_get(body, "container", "view_id") + + custom_fields: dict = region_record.custom_fields + custom_fields.pop(custom_field_name) + region_record.custom_fields = custom_fields + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + update_local_region_records() + build_custom_field_menu(body, client, logger, context, region_record, update_view_id=view_id) + + +def handle_custom_field_add(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + config_data = forms.CUSTOM_FIELD_ADD_EDIT_FORM.get_selected_values(body) + + custom_field_name = safe_get(config_data, actions.CUSTOM_FIELD_ADD_NAME) + custom_field_type = safe_get(config_data, actions.CUSTOM_FIELD_ADD_TYPE) + custom_field_options: str = safe_get(config_data, actions.CUSTOM_FIELD_ADD_OPTIONS) + # trim whitespace from options + custom_field_options = custom_field_options.strip() if custom_field_options else "" + + custom_fields = region_record.custom_fields or {} + custom_fields[custom_field_name] = { + "name": custom_field_name, + "type": custom_field_type, + "options": custom_field_options.split(",") if custom_field_options else [], + "enabled": True, + } + + if custom_field_type == "Dropdown" and not custom_field_options: + client.chat_postMessage( + channel=safe_get(body, "user", "id"), + text="Please provide a name and options for the custom field.", + ) + return + else: + region_record.custom_fields = custom_fields + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + update_local_region_records() + + previous_view_id = safe_get(body, "view", "previous_view_id") + build_custom_field_menu(body, client, logger, context, region_record, update_view_id=previous_view_id) + + +def handle_custom_field_menu( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + custom_fields = region_record.custom_fields or {} + + selected_values: dict = safe_get(body, "view", "state", "values") + + for key, value in selected_values.items(): + if key[: len(actions.CUSTOM_FIELD_ENABLE)] == actions.CUSTOM_FIELD_ENABLE: + custom_fields[key[len(actions.CUSTOM_FIELD_ENABLE) + 1 :]]["enabled"] = ( + value[key]["selected_option"]["value"] == "enable" + ) + region_record.custom_fields = custom_fields + + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + update_local_region_records() diff --git a/apps/slackbot/features/db_admin.py b/apps/slackbot/features/db_admin.py new file mode 100644 index 00000000..d380c3ad --- /dev/null +++ b/apps/slackbot/features/db_admin.py @@ -0,0 +1,460 @@ +import copy +import os +import ssl +from logging import Logger + +from alembic import command, config, script +from alembic.runtime import migration +from f3_data_models.models import Org, Org_Type, Org_x_SlackSpace, Role, Role_x_User_x_Org, SlackSpace +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient +from sqlalchemy import engine + +# from features.calendar.series import create_events +from scripts.calendar_images import generate_calendar_images +from scripts.q_lineups import send_lineups +from scripts.update_slack_users import update_slack_users +from utilities.database.orm import SlackSettings +from utilities.database.special_queries import get_admin_users +from utilities.helper_functions import ( + MapUpdateData, + # current_date_cst, + get_region_record, + get_user, + safe_convert, + safe_get, + trigger_map_revalidation, +) +from utilities.slack import actions, orm + + +def check_current_head(alembic_cfg: config.Config, connectable: engine.Engine) -> bool: + # type: (config.Config, engine.Engine) -> bool + directory = script.ScriptDirectory.from_config(alembic_cfg) + with connectable.begin() as connection: + context = migration.MigrationContext.configure(connection) + return set(context.get_current_heads()) == set(directory.get_heads()) + + +def build_db_admin_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + update_view_id: str = None, + message: str = None, +): + update_view_id = update_view_id or safe_get(body, actions.LOADING_ID) + if body.get("text") == os.environ.get("DB_ADMIN_PASSWORD") or message: + form = copy.deepcopy(DB_ADMIN_FORM) + # form.blocks[-1].label = message or " " + else: + form = copy.deepcopy(DB_WRONG_PASSWORD_FORM) + + form.update_modal( + client=client, + view_id=update_view_id, + callback_id=actions.DB_ADMIN_CALLBACK_ID, + title_text="DB Admin", + submit_button_text="Send Announcement", + ) + + +def handle_db_admin_upgrade( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + alembic_cfg = config.Config("alembic.ini") + command.upgrade(alembic_cfg, "head") + view_id = safe_get(body, "view", "id") + body["text"] = os.environ.get("DB_ADMIN_PASSWORD") + build_db_admin_form( + body, client, logger, context, region_record, update_view_id=view_id, message="Database upgraded!" + ) + + +def handle_db_admin_reset( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + alembic_cfg = config.Config("alembic.ini") + command.downgrade(alembic_cfg, "base") + command.upgrade(alembic_cfg, "head") + view_id = safe_get(body, "view", "id") + body["text"] = os.environ.get("DB_ADMIN_PASSWORD") + build_db_admin_form(body, client, logger, context, region_record, update_view_id=view_id, message="Database reset!") + + +def handle_calendar_image_refresh( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + generate_calendar_images(force=True) + + +def handle_slack_user_refresh( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + update_slack_users(force=True) + + +def handle_make_admin( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + slack_user_id = safe_get(body, "user", "id") + user = get_user(slack_user_id, region_record, client, logger) + DbManager.create_record( + Role_x_User_x_Org( + user_id=user.user_id, + org_id=region_record.org_id, + role_id=1, + ) + ) + + +def handle_ao_lineups( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + send_lineups(force=True) + + +def handle_preblast_reminders( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + from scripts.preblast_reminders import send_preblast_reminders + + send_preblast_reminders() + + +def handle_backblast_reminders( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + from scripts.backblast_reminders import send_backblast_reminders + + send_backblast_reminders(force=True) + + +# def handle_generate_instances( +# body: dict, +# client: WebClient, +# logger: Logger, +# context: dict, +# region_record: SlackSettings, +# ): +# event_records = DbManager.find_records( +# Event, +# filters=[ +# Event.is_active, +# or_(Event.org_id == region_record.org_id, Event.org.has(Org.parent_id == region_record.org_id)), +# or_(Event.end_date >= current_date_cst(), Event.end_date.is_(None)), +# ], +# joinedloads="all", +# ) +# start_date = ( +# safe_convert(region_record.migration_date, datetime.strptime, args=["%Y-%m-%d"]) or datetime.now() +# ).date() +# create_events(event_records, clear_first=True, start_date=start_date) + + +def handle_trigger_map_revalidation( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + action = "map.updated" + update_data = MapUpdateData( + eventId=42, + ) + success = trigger_map_revalidation( + action=action, + map_update_data=update_data, + ) + msg = "Map revalidation triggered successfully." if success else "Failed to trigger map revalidation." + form = copy.deepcopy(DB_ADMIN_FORM) + form.blocks.append(orm.SectionBlock(label=msg)) + form.update_modal( + client=client, + view_id=safe_get(body, "view", "id"), + callback_id=actions.DB_ADMIN_CALLBACK_ID, + title_text="DB Admin", + submit_button_text="Send Announcement", + ) + + +def handle_make_org( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + team_id = safe_get(body, "team", "id") or safe_get(body, "team_id") + region_record: SlackSettings = get_region_record(team_id, body, context, client, logger) + # Create a new region org record + org_record: Org = DbManager.create_record( + Org( + org_type=Org_Type.region, + name="My Region", + is_active=True, + ) + ) + # Connect the new org to the Slack space + if team_id: + slack_space_record = DbManager.find_first_record(SlackSpace, [SlackSpace.team_id == team_id]) + if slack_space_record: + connect_record = Org_x_SlackSpace( + org_id=org_record.id, + slack_space_id=slack_space_record.id, + ) + DbManager.create_record(connect_record) + # Update the slack space record with the new org + region_record.org_id = org_record.id + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + # Make the current user an admin of the new org + slack_user_id = safe_get(body, "user", "id") + user_id = get_user(slack_user_id, region_record, client, logger).user_id + admin_role_id = DbManager.find_first_record(Role, filters=[Role.name == "admin"]).id + DbManager.create_record( + Role_x_User_x_Org( + user_id=user_id, + org_id=org_record.id, + role_id=admin_role_id, + ) + ) + + +def handle_update_bot_token( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + bot_token = safe_get(context, "bot_token") + + if bot_token: + region_record.bot_token = bot_token + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={ + SlackSpace.bot_token: bot_token, + SlackSpace.settings: region_record.__dict__, + }, + ) + + +def handle_send_admin_announcement( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + announcement_text = safe_get( + body, "view", "state", "values", actions.DB_ADMIN_TEXT, actions.DB_ADMIN_TEXT, "rich_text_value" + ) + if announcement_text: + records = DbManager.find_join_records2( + SlackSpace, + Org_x_SlackSpace, + filters=[True], + ) + for record in records: + slack_space = record[0] + org_x_slack_space = record[1] + admin_users = get_admin_users(org_x_slack_space.org_id, slack_space.team_id) + slack_admin_ids = {safe_get(u, 1, "slack_id") for u in admin_users if safe_get(u, 1, "slack_id")} + for slack_user_id in slack_admin_ids: + try: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + slack_client = WebClient(slack_space.settings.get("bot_token"), ssl=ssl_context) + slack_client.chat_postMessage( + channel=slack_user_id, + blocks=[announcement_text], + text="Admin Announcement", + ) + except Exception as e: + logger.error( + f"Failed to send admin announcement to {slack_user_id} in {slack_space.workspace_name}: {e}" # noqa: E501 + ) + + +def build_long_run_task_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + form = orm.BlockView( + blocks=[ + orm.InputBlock( + action=actions.DB_ADMIN_LONG_RUN_SECONDS, + label="Task Duration (seconds)", + element=orm.NumberInputElement( + initial_value=5, + ), + ), + ] + ) + + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Long Run Task", + submit_button_text="Start Task", + callback_id=actions.DB_ADMIN_LONG_RUN_CALLBACK_ID, + new_or_add="add", + ) + + +def handle_refresh_cache( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + from utilities.helper_functions import update_local_region_records, update_local_slack_users + + update_local_region_records() + update_local_slack_users() + + +def handle_auto_preblast_send( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + from scripts.auto_preblast_send import send_automated_preblasts + + send_automated_preblasts(force=True) + + +def handle_long_run_task( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + form_data = DB_ADMIN_FORM.get_selected_values(body) + duration_seconds = safe_convert( + safe_get(form_data, actions.DB_ADMIN_LONG_RUN_SECONDS), + int, + default=5, + ) + import time + + client.chat_postMessage( + channel=safe_get(body, "user", "id"), + text=f"Starting long run task for {duration_seconds} seconds...", + ) + time.sleep(duration_seconds) + client.chat_postMessage( + channel=safe_get(body, "user", "id"), + text="Long run task complete.", + ) + + +DB_ADMIN_FORM = orm.BlockView( + blocks=[ + orm.ActionsBlock( + elements=[ + # orm.ButtonElement( + # label="AO Lineups", + # action=actions.SECRET_MENU_AO_LINEUPS, + # ), + # orm.ButtonElement( + # label="Preblast Reminders", + # action=actions.SECRET_MENU_PREBLAST_REMINDERS, + # ), + # orm.ButtonElement( + # label="Backblast Reminders", + # action=actions.SECRET_MENU_BACKBLAST_REMINDERS, + # ), + orm.ButtonElement( + label="Trigger Map Revalidation", + action=actions.SECRET_MENU_TRIGGER_MAP_REVALIDATION, + ), + # orm.ButtonElement( + # label="Refresh Slack Users", + # action=actions.SECRET_MENU_REFRESH_SLACK_USERS, + # ), + orm.ButtonElement( + label="Update Bot Token", + action=actions.SECRET_MENU_UPDATE_BOT_TOKEN, + ), + # orm.ButtonElement( + # label="Test long run task", + # action=actions.SECRET_MENU_LONG_RUN, + # ), + # orm.ButtonElement( + # label="Send Auto Preblasts", + # action=actions.SECRET_MENU_SEND_AUTO_PREBLASTS, + # ), + orm.ButtonElement( + label="Refresh Cache", + action=actions.SECRET_MENU_REFRESH_CACHE, + ), + ], + ), + orm.InputBlock( + action=actions.DB_ADMIN_TEXT, + label="Announcement Text", + element=orm.RichTextInputElement( + placeholder="Enter the announcement text here.", + ), + ), + ] +) + +DB_WRONG_PASSWORD_FORM = orm.BlockView( + blocks=[ + orm.SectionBlock( + action=actions.DB_ADMIN_TEXT, + label="Wrong password.", + ), + ] +) diff --git a/apps/slackbot/features/downrange.py b/apps/slackbot/features/downrange.py new file mode 100644 index 00000000..7e5d1072 --- /dev/null +++ b/apps/slackbot/features/downrange.py @@ -0,0 +1,897 @@ +import copy +import json +import ssl +from logging import Logger + +from f3_data_models.models import Org, Org_x_SlackSpace, SlackSpace, User +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient + +from utilities.constants import ALL_USERS_ARE_ADMINS +from utilities.database.orm import SlackSettings +from utilities.database.special_queries import get_admin_users +from utilities.helper_functions import ( + REGION_RECORDS, + get_user, + safe_convert, + safe_get, + update_local_region_records, +) +from utilities.slack import actions +from utilities.slack.orm import ( + ActionsBlock, + BlockView, + ButtonElement, + ChannelsSelectElement, + ContextBlock, + ContextElement, + DividerBlock, + ExternalSelectElement, + HeaderBlock, + InputBlock, + PlainTextInputElement, + RadioButtonsElement, + SectionBlock, + as_selector_options, +) + +# ────────────────────────────────────────────────────────────────────────────── +# Private helpers +# ────────────────────────────────────────────────────────────────────────────── + + +def _is_admin(body: dict, region_record: SlackSettings, client: WebClient, logger: Logger) -> bool: + if ALL_USERS_ARE_ADMINS: + return True + if not region_record.org_id: + return False + user_id = safe_get(body, "user_id") or safe_get(body, "user", "id") + slack_user = get_user(user_id, region_record, client, logger) + admin_users = get_admin_users(region_record.org_id, region_record.team_id) + return any( + u[0].id == slack_user.user_id for u in admin_users if u[1] and u[1].slack_id and u[1].slack_id != "USLACKBOT" + ) + + +def _get_target_region_info(org_id: int, logger: Logger) -> tuple: + """Return (team_id, bot_token, SlackSettings) for a region org, or (None, None, None) if not on Slack.""" + ox = DbManager.find_first_record(Org_x_SlackSpace, [Org_x_SlackSpace.org_id == org_id]) + if not ox: + return None, None, None + slack_space = DbManager.get(SlackSpace, ox.slack_space_id) + if not slack_space: + return None, None, None + team_id = slack_space.team_id + bot_token = slack_space.bot_token + settings = REGION_RECORDS.get(team_id) + if not settings and slack_space.settings: + try: + settings = SlackSettings(**slack_space.settings) + except Exception as e: + logger.warning(f"Could not load SlackSettings for team {team_id}: {e}") + return team_id, bot_token, settings + + +def _build_form_base(selected_org_id: int = None, selected_org_name: str = None) -> BlockView: + """Build a fresh BlockView containing just the region search input.""" + return BlockView( + blocks=[ + SectionBlock(label="*Search for a region*"), + ActionsBlock( + elements=[ + ExternalSelectElement( + placeholder="Type to search for a region...", + action=actions.DOWNRANGE_REGION_SELECT, + min_query_length=2, + initial_value=( + {"text": selected_org_name, "value": str(selected_org_id)} + if selected_org_id and selected_org_name + else None + ), + ), + ] + ), + DividerBlock(), + ActionsBlock( + elements=[ + ButtonElement( + label=":world_map: Nearby Special Events", + action=actions.NEARBY_EVENTS_OPEN, + ), + ] + ), + ] + ) + + +def _build_contact_info_blocks(org) -> list: + """Return display blocks showing available contact info for an Org. Returns [] if none set.""" + if not org: + return [] + lines = [] + if org.website: + lines.append(f"\u2022 *Website:* <{org.website}|{org.website}>") + if org.email: + lines.append(f"\u2022 *Email:* {org.email}") + if org.twitter: + lines.append(f"\u2022 *Twitter:* {org.twitter}") + if org.facebook: + lines.append(f"\u2022 *Facebook:* {org.facebook}") + if org.instagram: + lines.append(f"\u2022 *Instagram:* {org.instagram}") + if not lines: + return [] + return [SectionBlock(label=":phone: *Contact Information*\n" + "\n".join(lines))] + + +def _build_region_info_blocks( + has_slack: bool, + target_settings, + target_team_id: str, + target_bot_token: str, + target_org_id: int, + selected_org_name: str, + requester_bot_token: str, + requester_user_id: str, + requester_name: str, + requester_region_name: str, + requester_email: str = "", + org=None, +) -> list: + """Return the dynamic info blocks shown after a region is selected.""" + blocks = [DividerBlock()] + + if not has_slack: + blocks.append(SectionBlock(label=f"*{selected_org_name}* is not known to be on Slack.")) + blocks.extend(_build_contact_info_blocks(org)) + return blocks + + payload = json.dumps( + { + "target_team_id": target_team_id, + "target_bot_token": target_bot_token, + "target_org_id": target_org_id, + "target_org_name": selected_org_name, + "requester_bot_token": requester_bot_token, + "requester_user_id": requester_user_id, + "requester_name": requester_name, + "requester_region_name": requester_region_name, + "requester_email": requester_email, + } + ) + + if ( + target_settings + and target_settings.downrange_invite_sharing == "proactive" + and target_settings.downrange_invite_link + ): + blocks.append( + SectionBlock(label=f":white_check_mark: *{selected_org_name}* is on Slack! Here is their invite link:") + ) + blocks.append(SectionBlock(label=target_settings.downrange_invite_link)) + blocks.append( + ContextBlock( + element=ContextElement( + initial_value="If this link doesn't work, you can still request an invite from the region admins below." # noqa: E501 + ) + ) + ) + blocks.append( + ActionsBlock( + elements=[ + ButtonElement( + label=":envelope: Request Invite Instead", + action=actions.DOWNRANGE_INVITE_REQUEST_BUTTON, + value=payload, + ), + ] + ) + ) + elif target_settings and target_settings.downrange_invite_sharing == "direct_email": + blocks.append( + SectionBlock( + label=f":email: *{selected_org_name}* is on Slack! They accept invite requests via direct email." + ) + ) + blocks.append( + ContextBlock( + element=ContextElement( + initial_value=( + f"Your email address ({requester_email or 'on file with Slack'}) will be shared " + "with their admins so they can send you a direct Slack invite." + ) + ) + ) + ) + blocks.append( + InputBlock( + label="Introduction", + action=actions.DOWNRANGE_INTRO_TEXT, + element=PlainTextInputElement( + placeholder="Your F3 name, home region, why you want to join...", + multiline=True, + ), + optional=True, + ) + ) + blocks.append( + ActionsBlock( + elements=[ + ButtonElement( + label=":envelope: Send Request for Invite", + action=actions.DOWNRANGE_INVITE_REQUEST_BUTTON, + value=payload, + ), + ] + ) + ) + else: + blocks.append( + SectionBlock( + label=f":airplane: *{selected_org_name}* is on Slack! You can request an invite from their admins." + ) + ) + blocks.append( + InputBlock( + label="Introduction", + action=actions.DOWNRANGE_INTRO_TEXT, + element=PlainTextInputElement( + placeholder="Your F3 name, home region, why you want to join...", + multiline=True, + ), + optional=True, + ) + ) + blocks.append( + ActionsBlock( + elements=[ + ButtonElement( + label=":envelope: Send Request for Invite", + action=actions.DOWNRANGE_INVITE_REQUEST_BUTTON, + value=payload, + ), + ] + ) + ) + blocks.extend(_build_contact_info_blocks(org)) + return blocks + + +def _get_radio_value(state_values: dict, block_id: str): + return safe_get(state_values, block_id, block_id, "selected_option", "value") + + +def _get_text_value(state_values: dict, block_id: str): + return safe_get(state_values, block_id, block_id, "value") + + +def _get_channel_value(state_values: dict, block_id: str): + return safe_get(state_values, block_id, block_id, "selected_channel") + + +def _ssl_context(): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +# Blocks for the admin settings section (appended below the search when user is admin) +DOWNRANGE_ADMIN_BLOCKS = [ + DividerBlock(), + HeaderBlock(label=":lock: Admin: Downrange Settings"), + InputBlock( + label="Invite link sharing", + action=actions.DOWNRANGE_INVITE_SHARING, + element=RadioButtonsElement( + initial_value="request_only", + options=as_selector_options( + names=["Share invite link proactively", "Require a request for invite", "Direct email invite"], + values=["proactive", "request_only", "direct_email"], + ), + ), + optional=False, + hint=( + "Proactive: any bot user can see your invite link directly. " + "Request: users must request an invite, and you approve it. " + "Direct email: the requester's email is shared with your admins so they can send a direct Slack invite." + ), + ), + InputBlock( + label="Slack invite link", + action=actions.DOWNRANGE_INVITE_LINK, + element=PlainTextInputElement( + placeholder="https://join.slack.com/t/your-workspace/...", + ), + optional=True, + hint=( + "To get your invite link: Slack → Settings → Invitations → Invite people → Copy link. " + "You can set the link to never expire. Even if you choose 'Require a request', you can " + "pre-fill this so admins can approve requests with one click." + ), + ), + DividerBlock(), + InputBlock( + label="Downrange backblast cross-posting", + action=actions.DOWNRANGE_CHANNEL_POSTING, + element=RadioButtonsElement( + initial_value="off", + options=as_selector_options( + names=["Off", "Enabled"], + values=["off", "enabled"], + ), + ), + optional=False, + hint=( + "When enabled, backblasts posted in other regions that tag PAX from your region " + "will be cross-posted to the channel below." + ), + ), + InputBlock( + label="Cross-post channel", + action=actions.DOWNRANGE_CHANNEL, + element=ChannelsSelectElement(placeholder="Select a channel..."), + optional=True, + hint="The channel in your workspace where downrange backblasts will be cross-posted.", + ), +] + + +# ────────────────────────────────────────────────────────────────────────────── +# Public handlers +# ────────────────────────────────────────────────────────────────────────────── + + +def build_downrange_menu(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + update_view_id = safe_get(body, actions.LOADING_ID) + is_admin_user = _is_admin(body, region_record, client, logger) + + form = _build_form_base() + + if is_admin_user: + for block in copy.deepcopy(DOWNRANGE_ADMIN_BLOCKS): + form.add_block(block) + form.set_initial_values( + { + actions.DOWNRANGE_INVITE_SHARING: region_record.downrange_invite_sharing or "request_only", + actions.DOWNRANGE_INVITE_LINK: region_record.downrange_invite_link or "", + actions.DOWNRANGE_CHANNEL_POSTING: region_record.downrange_channel_posting or "off", + actions.DOWNRANGE_CHANNEL: region_record.downrange_channel, + } + ) + submit_text = "Save Admin Settings" + else: + submit_text = "None" + + metadata = {"is_admin": is_admin_user} + + if update_view_id: + form.update_modal( + client=client, + view_id=update_view_id, + title_text="Downrange", + callback_id=actions.DOWNRANGE_CALLBACK_ID, + submit_button_text=submit_text, + parent_metadata=metadata, + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Downrange", + callback_id=actions.DOWNRANGE_CALLBACK_ID, + new_or_add="add", + submit_button_text=submit_text, + parent_metadata=metadata, + ) + + +def handle_region_select(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + """Dispatch-action handler: user selected a region from the ExternalSelectElement.""" + selected_option = safe_get(body, "actions", 0, "selected_option") + if not selected_option: + return + + selected_org_id = safe_convert(selected_option.get("value"), int) + # selected_option.text is {"type": "plain_text", "text": "Region Name", "emoji": True} + selected_org_name = safe_get(selected_option, "text", "text") or "" + view_id = safe_get(body, "view", "id") + user_id = safe_get(body, "user_id") or safe_get(body, "user", "id") + + private_metadata = json.loads(safe_get(body, "view", "private_metadata") or "{}") + is_admin_user = private_metadata.get("is_admin", False) + + # Look up requester info + requester_slack_user = get_user(user_id, region_record, client, logger) + requester_name = requester_slack_user.user_name or "Unknown" + requester_email = requester_slack_user.email or "" + requester_region_name = None + if requester_slack_user.user_id: + requester_user = DbManager.get(User, requester_slack_user.user_id, joinedloads=[User.home_region_org]) + if requester_user and requester_user.home_region_org: + requester_region_name = requester_user.home_region_org.name + if not requester_region_name and region_record.org_id: + region_org = DbManager.get(Org, region_record.org_id) + requester_region_name = region_org.name if region_org else None + + # Look up target region + target_team_id, target_bot_token, target_settings = _get_target_region_info(selected_org_id, logger) + org = DbManager.get(Org, selected_org_id) + + # Rebuild the form with the selection preserved + form = _build_form_base(selected_org_id=selected_org_id, selected_org_name=selected_org_name) + + # Add dynamic info blocks + info_blocks = _build_region_info_blocks( + has_slack=target_team_id is not None, + target_settings=target_settings, + target_team_id=target_team_id, + target_bot_token=target_bot_token, + target_org_id=selected_org_id, + selected_org_name=selected_org_name, + requester_bot_token=region_record.bot_token, + requester_user_id=user_id, + requester_name=requester_name, + requester_region_name=requester_region_name, + requester_email=requester_email, + org=org, + ) + for block in info_blocks: + form.add_block(block) + + # Re-add admin section, restoring current form values + if is_admin_user: + current_values = safe_get(body, "view", "state", "values") or {} + for block in copy.deepcopy(DOWNRANGE_ADMIN_BLOCKS): + form.add_block(block) + form.set_initial_values( + { + actions.DOWNRANGE_INVITE_SHARING: _get_radio_value(current_values, actions.DOWNRANGE_INVITE_SHARING) + or region_record.downrange_invite_sharing + or "request_only", + actions.DOWNRANGE_INVITE_LINK: _get_text_value(current_values, actions.DOWNRANGE_INVITE_LINK) + or region_record.downrange_invite_link + or "", + actions.DOWNRANGE_CHANNEL_POSTING: _get_radio_value(current_values, actions.DOWNRANGE_CHANNEL_POSTING) + or region_record.downrange_channel_posting + or "off", + actions.DOWNRANGE_CHANNEL: _get_channel_value(current_values, actions.DOWNRANGE_CHANNEL) + or region_record.downrange_channel, + } + ) + submit_text = "Save Admin Settings" + else: + submit_text = "Done" + + form.update_modal( + client=client, + view_id=view_id, + title_text="Downrange", + callback_id=actions.DOWNRANGE_CALLBACK_ID, + submit_button_text=submit_text, + parent_metadata=private_metadata, + ) + + +def handle_invite_request(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + """User clicked 'Send Request for Invite'. DM the target region's admins.""" + action_value = json.loads(safe_get(body, "actions", 0, "value") or "{}") + target_team_id = action_value.get("target_team_id") + target_bot_token = action_value.get("target_bot_token") + target_org_id = safe_convert(action_value.get("target_org_id"), int) + target_org_name = action_value.get("target_org_name", "the region") + requester_bot_token = action_value.get("requester_bot_token") or region_record.bot_token + requester_user_id = action_value.get("requester_user_id") or safe_get(body, "user", "id") + requester_name = action_value.get("requester_name", "Unknown") + requester_region_name = action_value.get("requester_region_name", "Unknown") + requester_email = action_value.get("requester_email", "") + + # Get intro text from form state (may be None) + state_values = safe_get(body, "view", "state", "values") or {} + intro_text = _get_text_value(state_values, actions.DOWNRANGE_INTRO_TEXT) or "" + + if not target_team_id or not target_bot_token: + logger.error("handle_invite_request: missing target_team_id or target_bot_token") + return + + if not target_org_id: + logger.error("handle_invite_request: missing target_org_id") + return + + # Find the target region's admin Slack users + admin_users = get_admin_users(target_org_id, target_team_id) + admin_slack_ids = [ + au[1].slack_id for au in admin_users if au[1] and au[1].slack_id and au[1].slack_id != "USLACKBOT" + ] + + if not admin_slack_ids: + # No admin Slack users found — show error via modal update + error_form = BlockView( + blocks=[ + SectionBlock( + label=f"Sorry, we couldn't find any admin users for *{target_org_name}* to send your request to. " + "Try reaching out to them through other channels." + ) + ] + ) + error_form.update_modal( + client=client, + view_id=safe_get(body, "view", "id"), + title_text="Downrange", + callback_id=actions.DOWNRANGE_CALLBACK_ID, + submit_button_text="None", + ) + return + + # Get the target region's current invite-sharing setting to determine DM format + _, _, target_settings = _get_target_region_info(target_org_id, logger) + is_direct_email = target_settings and target_settings.downrange_invite_sharing == "direct_email" + + # Build request message for target admins + intro_section = f"\n\n*Their intro:*\n{intro_text}" if intro_text else "" + metadata = { + "event_type": "downrange_invite_request", + "event_payload": { + "requester_bot_token": requester_bot_token, + "requester_user_id": requester_user_id, + "requester_name": requester_name, + "requester_region_name": requester_region_name, + "target_org_name": target_org_name, + }, + } + + if is_direct_email: + dm_text = f"Downrange email invite request from {requester_name} ({requester_region_name})" + message_text = ( + f":email: *Downrange Email Invite Request*\n" + f"*{requester_name}* from *{requester_region_name}* would like to join your Slack workspace." + f"{intro_section}\n\n*Their email:* `{requester_email}`" + ) + request_blocks = [ + SectionBlock(label=message_text).as_form_field(), + ContextBlock( + element=ContextElement( + initial_value=( + "To invite them, go to your Slack workspace *Settings* \u2192 *Invite People* " + "and enter their email address above." + ) + ) + ).as_form_field(), + ActionsBlock( + elements=[ + ButtonElement( + label=":white_check_mark: Mark as Invited", + action=actions.DOWNRANGE_INVITE_MARK_DONE_BUTTON, + style="primary", + value="done", + ), + ButtonElement( + label=":x: Deny", + action=actions.DOWNRANGE_INVITE_DENY_BUTTON, + style="danger", + value="deny", + ), + ] + ).as_form_field(), + ] + else: + dm_text = f"Downrange invite request from {requester_name} ({requester_region_name})" + message_text = ( + f":airplane: *Downrange Invite Request*\n" + f"*{requester_name}* from *{requester_region_name}* would like to join your Slack workspace.{intro_section}" + ) + request_blocks = [ + SectionBlock(label=message_text).as_form_field(), + ActionsBlock( + elements=[ + ButtonElement( + label=":white_check_mark: Approve & Send Invite", + action=actions.DOWNRANGE_INVITE_APPROVE_BUTTON, + style="primary", + value="approve", + ), + ButtonElement( + label=":x: Deny", + action=actions.DOWNRANGE_INVITE_DENY_BUTTON, + style="danger", + value="deny", + ), + ] + ).as_form_field(), + ContextBlock( + element=ContextElement( + initial_value=( + "To set or update your workspace invite link, open `/f3-nation-settings` \u2192 Downrange \u2192 Admin settings." # noqa: E501 + ) + ) + ).as_form_field(), + ] + + try: + target_client = WebClient(token=target_bot_token, ssl=_ssl_context()) + dm = target_client.conversations_open(users=",".join(admin_slack_ids)) + dm_channel = safe_get(dm, "channel", "id") + target_client.chat_postMessage( + channel=dm_channel, + text=dm_text, + blocks=request_blocks, + metadata=metadata, + ) + except Exception as e: + logger.error(f"handle_invite_request: error sending DM to target admins: {e}") + return + + # Update the requester's modal to confirm the request was sent + confirm_form = BlockView( + blocks=[ + SectionBlock( + label=f":white_check_mark: Your invite request has been sent to the admins of *{target_org_name}*! " + "They'll DM you with an invite link once approved." + ) + ] + ) + confirm_form.update_modal( + client=client, + view_id=safe_get(body, "view", "id"), + title_text="Downrange", + callback_id=actions.DOWNRANGE_CALLBACK_ID, + submit_button_text="None", + ) + + +def handle_invite_approve(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + """Admin clicked 'Approve & Send Invite'. Send the invite link to the requester.""" + metadata = safe_get(body, "message", "metadata", "event_payload") or {} + requester_bot_token = metadata.get("requester_bot_token") + requester_user_id = metadata.get("requester_user_id") + requester_name = metadata.get("requester_name", "the requester") + target_org_name = metadata.get("target_org_name", "our region") + + channel_id = safe_get(body, "channel", "id") + message_ts = safe_get(body, "message", "ts") + + invite_link = region_record.downrange_invite_link + + if not invite_link: + # Admin needs to set the invite link first + try: + client.chat_postEphemeral( + channel=channel_id, + user=safe_get(body, "user", "id"), + text=( + ":warning: You haven't set an invite link yet. " + "Go to `/f3-nation-settings` → Downrange, enter your invite link, then come back and approve." + ), + ) + except Exception as e: + logger.error(f"handle_invite_approve: error posting ephemeral: {e}") + return + + # Build the approval DM blocks for the requester + approve_blocks = [ + SectionBlock( + label=f":tada: Your invite request to join *{target_org_name}* on Slack has been approved!" + ).as_form_field(), + SectionBlock(label=f"*Invite link:* {invite_link}").as_form_field(), + ActionsBlock( + elements=[ + ButtonElement( + label=":question: That link didn't work", + action=actions.DOWNRANGE_INVITE_LINK_BROKEN_BUTTON, + value=json.dumps( + { + "target_bot_token": region_record.bot_token, + "target_team_id": region_record.team_id, + "target_org_id": region_record.org_id, + "target_org_name": target_org_name, + "requester_name": requester_name, + } + ), + ), + ] + ).as_form_field(), + ] + + if not requester_bot_token or not requester_user_id: + logger.error("handle_invite_approve: missing requester_bot_token or requester_user_id in metadata") + return + + try: + send_client = WebClient(token=requester_bot_token, ssl=_ssl_context()) + send_client.chat_postMessage( + channel=requester_user_id, + text=f"Your invite request to {target_org_name} has been approved!", + blocks=approve_blocks, + ) + except Exception as e: + logger.error(f"handle_invite_approve: error sending invite to requester: {e}") + return + + # Update the admin group DM to remove the action buttons + if channel_id and message_ts: + try: + client.chat_update( + channel=channel_id, + ts=message_ts, + text=f"Invite request from {requester_name} — *Approved* :white_check_mark:", + blocks=[ + SectionBlock( + label=f"Invite request from *{requester_name}* — *Approved* :white_check_mark: by <@{safe_get(body, 'user', 'id')}>" # noqa: E501 + ).as_form_field() + ], + ) + except Exception as e: + logger.error(f"handle_invite_approve: error updating admin message: {e}") + + +def handle_invite_deny(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + """Admin clicked 'Deny'. Notify the requester politely.""" + metadata = safe_get(body, "message", "metadata", "event_payload") or {} + requester_bot_token = metadata.get("requester_bot_token") + requester_user_id = metadata.get("requester_user_id") + requester_name = metadata.get("requester_name", "the requester") + target_org_name = metadata.get("target_org_name", "our region") + + channel_id = safe_get(body, "channel", "id") + message_ts = safe_get(body, "message", "ts") + + if not requester_bot_token or not requester_user_id: + logger.error("handle_invite_deny: missing requester_bot_token or requester_user_id in metadata") + return + + try: + send_client = WebClient(token=requester_bot_token, ssl=_ssl_context()) + send_client.chat_postMessage( + channel=requester_user_id, + text=f"Your invite request to {target_org_name} was not approved at this time.", + blocks=[ + SectionBlock( + label=f"Your invite request to join *{target_org_name}* on Slack was not approved at this time. " + "Feel free to reach out to them directly for more information." + ).as_form_field() + ], + ) + except Exception as e: + logger.error(f"handle_invite_deny: error sending denial to requester: {e}") + return + + # Update the admin group DM + if channel_id and message_ts: + try: + client.chat_update( + channel=channel_id, + ts=message_ts, + text=f"Invite request from {requester_name} — *Denied* :x:", + blocks=[ + SectionBlock( + label=f"Invite request from *{requester_name}* — *Denied* :x: by <@{safe_get(body, 'user', 'id')}>" # noqa: E501 + ).as_form_field() + ], + ) + except Exception as e: + logger.error(f"handle_invite_deny: error updating admin message: {e}") + + +def handle_invite_link_broken( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + """Requester clicked 'That link didn't work'. Notify the target region admins.""" + action_value = json.loads(safe_get(body, "actions", 0, "value") or "{}") + target_bot_token = action_value.get("target_bot_token") + target_team_id = action_value.get("target_team_id") + target_org_id = safe_convert(action_value.get("target_org_id"), int) + target_org_name = action_value.get("target_org_name", "the region") + requester_name = action_value.get("requester_name", "A user") + + channel_id = safe_get(body, "channel", "id") + requester_user_id = safe_get(body, "user", "id") + + # Notify the target region admins that the link didn't work + if target_bot_token and target_team_id and target_org_id: + admin_users = get_admin_users(target_org_id, target_team_id) + admin_slack_ids = [ + au[1].slack_id for au in admin_users if au[1] and au[1].slack_id and au[1].slack_id != "USLACKBOT" + ] + if admin_slack_ids: + try: + target_client = WebClient(token=target_bot_token, ssl=_ssl_context()) + dm = target_client.conversations_open(users=",".join(admin_slack_ids)) + dm_channel = safe_get(dm, "channel", "id") + target_client.chat_postMessage( + channel=dm_channel, + text=f":warning: {requester_name} reported that the invite link for {target_org_name} didn't work.", + blocks=[ + SectionBlock( + label=f":warning: *{requester_name}* reported that the invite link you sent didn't work. " + "Please update your invite link in `/f3-nation-settings` → Downrange and send them a new one." # noqa: E501 + ).as_form_field() + ], + ) + except Exception as e: + logger.error(f"handle_invite_link_broken: error notifying target admins: {e}") + + # Tell the requester their report was sent and suggest next steps + try: + client.chat_postEphemeral( + channel=channel_id, + user=requester_user_id, + text=( + f"We've notified the admins of *{target_org_name}* that the link didn't work. " + "They'll send you an updated link shortly." + ), + ) + except Exception as e: + logger.error(f"handle_invite_link_broken: error notifying requester: {e}") + + +def handle_invite_mark_done(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + """Admin clicked 'Mark as Invited'. Update the DM and notify the requester to check their email.""" + metadata = safe_get(body, "message", "metadata", "event_payload") or {} + requester_bot_token = metadata.get("requester_bot_token") + requester_user_id = metadata.get("requester_user_id") + requester_name = metadata.get("requester_name", "the requester") + target_org_name = metadata.get("target_org_name", "our region") + + channel_id = safe_get(body, "channel", "id") + message_ts = safe_get(body, "message", "ts") + + if not requester_bot_token or not requester_user_id: + logger.error("handle_invite_mark_done: missing requester_bot_token or requester_user_id in metadata") + return + + try: + send_client = WebClient(token=requester_bot_token, ssl=_ssl_context()) + send_client.chat_postMessage( + channel=requester_user_id, + text=f"Your invite to {target_org_name} has been sent — check your email!", + blocks=[ + SectionBlock( + label=f":email: The admins of *{target_org_name}* have sent you a direct email invite! " + "Check your email inbox for an invitation to join their Slack workspace." + ).as_form_field() + ], + ) + except Exception as e: + logger.error(f"handle_invite_mark_done: error notifying requester: {e}") + return + + # Update the admin group DM to mark as handled + if channel_id and message_ts: + try: + client.chat_update( + channel=channel_id, + ts=message_ts, + text=f"Email invite request from {requester_name} — *Invited* :white_check_mark:", + blocks=[ + SectionBlock( + label=f"Email invite request from *{requester_name}* — *Invited* :white_check_mark: by <@{safe_get(body, 'user', 'id')}>" # noqa: E501 + ).as_form_field() + ], + ) + except Exception as e: + logger.error(f"handle_invite_mark_done: error updating admin message: {e}") + + +def handle_downrange_settings( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + """Admin submitted the downrange settings form. Persist changes to SlackSettings.""" + private_metadata = json.loads(safe_get(body, "view", "private_metadata") or "{}") + if not private_metadata.get("is_admin", False): + return + + from f3_data_models.models import SlackSpace + + form_data = BlockView(blocks=copy.deepcopy(DOWNRANGE_ADMIN_BLOCKS)).get_selected_values(body) + + region_record.downrange_invite_sharing = safe_get(form_data, actions.DOWNRANGE_INVITE_SHARING) or "request_only" + region_record.downrange_invite_link = safe_get(form_data, actions.DOWNRANGE_INVITE_LINK) or None + region_record.downrange_channel_posting = safe_get(form_data, actions.DOWNRANGE_CHANNEL_POSTING) or "off" + region_record.downrange_channel = safe_get(form_data, actions.DOWNRANGE_CHANNEL) or None + + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + update_local_region_records() diff --git a/apps/slackbot/features/emergency.py b/apps/slackbot/features/emergency.py new file mode 100644 index 00000000..093cd5ec --- /dev/null +++ b/apps/slackbot/features/emergency.py @@ -0,0 +1,292 @@ +import copy +from datetime import datetime +from logging import Logger + +from f3_data_models.models import Org, SlackUser, User +from f3_data_models.utils import DbManager +from slack_sdk import WebClient + +from utilities.builders import add_loading_form +from utilities.database.orm import SlackSettings +from utilities.helper_functions import get_user, safe_get +from utilities.sendmail import send_via_sendgrid +from utilities.slack import actions +from utilities.slack.orm import ( + BlockView, + ContextBlock, + ContextElement, + DividerBlock, + ExternalSelectElement, + HeaderBlock, + InputBlock, + SectionBlock, + UsersSelectElement, +) + +# Action IDs +EMERGENCY_SEARCH_FORM_ID = "emergency_search_form_id" +EMERGENCY_LOCAL_USER_SELECT = "emergency_local_user_select" +EMERGENCY_DR_USER_SELECT = "emergency_dr_user_select" +EMERGENCY_INFO_CALLBACK_ID = "emergency_info_callback_id" + +# Meta key for DR sharing (used in user.meta) +USER_EMERGENCY_INFO_DR_SHARING = "user_emergency_info_dr_sharing" + + +def build_emergency_search_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + """Build the emergency info search modal with local and external user selectors.""" + form = copy.deepcopy(EMERGENCY_SEARCH_FORM) + + if safe_get(body, actions.LOADING_ID): + form.update_modal( + client=client, + view_id=safe_get(body, actions.LOADING_ID), + title_text="Emergency Info", + callback_id=EMERGENCY_SEARCH_FORM_ID, + submit_button_text="None", + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Emergency Info", + callback_id=EMERGENCY_SEARCH_FORM_ID, + new_or_add="add", + submit_button_text="None", + ) + + +def handle_local_user_select( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + """Handle selection of a local Slack user to view their emergency info.""" + selected_user_id = safe_get(body, "actions", 0, "selected_user") + if not selected_user_id: + return + + # Get the SlackUser and associated User record + slack_user: SlackUser = get_user(selected_user_id, region_record, client, logger) + if not slack_user or not slack_user.user_id: + _show_error_modal(client, body, "User not found in the system.") + return + + user = DbManager.get(User, slack_user.user_id) + if not user: + _show_error_modal(client, body, "User record not found.") + return + + _show_emergency_info_modal(client, body, user, is_local=True, region_record=region_record, logger=logger) + + +def handle_dr_user_select(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + """Handle selection of an external (downrange) user to view their emergency info.""" + selected_value = safe_get(body, "actions", 0, "selected_option", "value") + if not selected_value: + return + + try: + user_id = int(selected_value) + except (ValueError, TypeError): + _show_error_modal(client, body, "Invalid user selection.") + return + + user = DbManager.get(User, user_id) + if not user: + _show_error_modal(client, body, "User not found.") + return + + # Check if user has opted into DR sharing + dr_sharing_enabled = safe_get(user.meta, USER_EMERGENCY_INFO_DR_SHARING) + if not dr_sharing_enabled: + _show_error_modal( + client, + body, + "This user has not enabled downrange emergency info sharing. " + "Their emergency information cannot be accessed from outside their Slack workspace.", + ) + return + + _show_emergency_info_modal(client, body, user, is_local=False, region_record=region_record, logger=logger) + + +def _show_emergency_info_modal( + client: WebClient, + body: dict, + user: User, + is_local: bool, + region_record: SlackSettings, + logger: Logger, +): + update_view_id = add_loading_form(body, client, new_or_add="add") + + """Display the emergency information for a user.""" + # Send notification to the user whose info was accessed + accessing_user = get_user(safe_get(body, "user", "id") or safe_get(body, "user_id"), region_record, client, logger) + accessing_region_name = ( + safe_get(DbManager.get(Org, region_record.org_id), "name") if region_record.org_id else None + ) or "Unknown Region" + + if accessing_user: + accessing_user_name = accessing_user.user_name or "Unknown" + else: + accessing_user_name = safe_get(body, "user", "name") or safe_get(body, "user", "username") or "Unknown" + + _notify_user_of_access(user, accessing_user_name, is_local, accessing_region_name) + + blocks = [ + HeaderBlock(label=f"Emergency Info for {user.f3_name or 'Unknown'}").as_form_field(), + DividerBlock().as_form_field(), + ] + + # Emergency contact information + if user.emergency_contact: + blocks.append( + SectionBlock( + label=f"*Emergency Contact:* {user.emergency_contact}", + ).as_form_field() + ) + else: + blocks.append( + SectionBlock( + label="*Emergency Contact:* _Not provided_", + ).as_form_field() + ) + + if user.emergency_phone: + blocks.append( + SectionBlock( + label=f"*Phone:* {user.emergency_phone}", + ).as_form_field() + ) + else: + blocks.append( + SectionBlock( + label="*Phone:* _Not provided_", + ).as_form_field() + ) + + if user.emergency_notes: + blocks.append( + SectionBlock( + label=f"*Notes:* {user.emergency_notes}", + ).as_form_field() + ) + + blocks.append(DividerBlock().as_form_field()) + blocks.append( + ContextBlock( + element=ContextElement( + initial_value=":warning: The user has been emailed to let them know that their emergency information was accessed." # noqa: E501 + ) + ).as_form_field() + ) + + client.views_update( + view_id=update_view_id, + view={ + "type": "modal", + "callback_id": EMERGENCY_INFO_CALLBACK_ID, + "title": {"type": "plain_text", "text": "Emergency Info"}, + "close": {"type": "plain_text", "text": "Close"}, + "blocks": blocks, + }, + ) + + +def _notify_user_of_access(user: User, accessing_user_name: str, is_local: bool, accessing_region_name: str): + """Send email notification to user that their emergency info was accessed.""" + if not user.email: + return + + access_type = "local Slack workspace" if is_local else f"downrange search from {accessing_region_name}" + timestamp = datetime.now().strftime("%B %d, %Y at %I:%M %p UTC") + + html_content = f""" + + +

Emergency Information Access Notification

+

Hello {user.f3_name or "PAX"},

+

This is to notify you that your emergency contact information was accessed + through the F3 Nation Slackbot.

+
+

Accessed by: {accessing_user_name}

+

Access method: {access_type}

+

Time: {timestamp}

+
+

If you did not expect this access or have concerns, please reach out to your local F3 leadership.

+

+ This notification was sent because you have emergency contact information stored in the F3 Nation database. + You can manage your emergency information settings through your Slack workspace's F3 Nation slackbot, + using the `/f3-nation-settings` command. +

+ + + """ + + send_via_sendgrid( + to_email=user.email, + subject="F3 Nation - Your Emergency Information Was Accessed", + html_content=html_content, + from_email="F3 Nation Slackbot Support ", + ) + + +def _show_error_modal(client: WebClient, body: dict, message: str): + """Display an error message in a modal.""" + client.views_push( + trigger_id=safe_get(body, "trigger_id"), + view={ + "type": "modal", + "title": {"type": "plain_text", "text": "Error"}, + "close": {"type": "plain_text", "text": "Close"}, + "blocks": [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f":x: {message}"}, + } + ], + }, + ) + + +EMERGENCY_SEARCH_FORM = BlockView( + blocks=[ + HeaderBlock(label="Search for Emergency Info"), + ContextBlock( + element=ContextElement( + initial_value=":warning: *Important:* The user will be emailed to let them know that their emergency " + "information was accessed. Only use this feature in genuine emergency situations." + ) + ), + DividerBlock(), + SectionBlock(label="*Option 1: Local Slack User*"), + ContextBlock(element=ContextElement(initial_value="Search for a user in this Slack workspace.")), + InputBlock( + label="Select Local User", + action=EMERGENCY_LOCAL_USER_SELECT, + element=UsersSelectElement(placeholder="Select a user from this workspace"), + optional=True, + dispatch_action=True, + ), + DividerBlock(), + SectionBlock(label="*Option 2: Downrange User Search*"), + ContextBlock( + element=ContextElement( + initial_value="Search for users from other regions who have opted into downrange sharing. " + "Start typing a user's F3 name to search." + ) + ), + InputBlock( + label="Search External User", + action=EMERGENCY_DR_USER_SELECT, + element=ExternalSelectElement( + placeholder="Type to search for a user by F3 name", + min_query_length=2, + ), + optional=True, + dispatch_action=True, + ), + ] +) diff --git a/apps/slackbot/features/help.py b/apps/slackbot/features/help.py new file mode 100644 index 00000000..feb1d222 --- /dev/null +++ b/apps/slackbot/features/help.py @@ -0,0 +1,102 @@ +import json +import os +from logging import Logger + +from slack_sdk.models.blocks import ActionsBlock, ButtonElement, SectionBlock +from slack_sdk.web import WebClient + +from features.user import IGNORE_EVENT +from utilities.database.orm import SlackSettings +from utilities.helper_functions import get_user, safe_get +from utilities.slack import actions + + +def build_help_menu( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + with open(os.path.join(os.path.dirname(os.path.dirname(__file__)), "utilities", "default_help.json")) as f: + default_help_text = json.load(f) + + existing_view = body.get("view", {}) + + button_block = ActionsBlock( + elements=[ + ButtonElement( + text=":calendar: Open Calendar", + action_id=actions.OPEN_CALENDAR_BUTTON, + ), + ButtonElement( + text=":memo: Create Preblast", + action_id=actions.PREBLAST_NEW_BUTTON, + ), + ] + ).to_dict() + + if default_help_text: # TODO: customize per region in the future + view = { + "type": "modal", + "title": {"type": "plain_text", "text": "Help Menu"}, + "close": {"type": "plain_text", "text": "Close"}, + "blocks": [button_block, default_help_text], + } + if existing_view: + client.views_update(view_id=existing_view.get("id"), view=view) + else: + client.views_open(trigger_id=body.get("trigger_id"), view=view) + + +def handle_app_mention( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + slack_user_id = body["event"]["user"] + user_id = safe_get(get_user(slack_user_id, region_record, client, logger), "user_id") + blocks = [ + SectionBlock( + text="Hi there! Looking for me? :wave:\n\nHere are some things I can help you with:", + ), + ActionsBlock( + elements=[ + ButtonElement( + text=":calendar: Open Calendar", + action_id=actions.OPEN_CALENDAR_BUTTON, + ), + ButtonElement( + text=":memo: Create Preblast", + action_id=actions.PREBLAST_NEW_BUTTON, + ), + ButtonElement( + text=":question: Help Menu", + action_id=actions.CONFIG_HELP_MENU, + ), + ButtonElement( + text=":gear: Settings", + action_id=actions.SETTINGS_BUTTON, + ), + ] + ), + ] + if user_id: + blocks[1].elements.append( + ButtonElement( + text=":bar_chart: My Stats :link:", + url=f"{os.getenv('STATS_URL')}/stats/pax/{user_id}", + action=IGNORE_EVENT, + ) + ) + try: + client.chat_postEphemeral( + channel=body["event"]["channel"], + user=body["event"]["user"], + text="Hi there! Looking for me? :wave:", + blocks=blocks, + ) + except Exception as e: + logger.error(f"Error handling app mention: {e}") diff --git a/apps/slackbot/features/paxminer_mapping.py b/apps/slackbot/features/paxminer_mapping.py new file mode 100644 index 00000000..564c2dfb --- /dev/null +++ b/apps/slackbot/features/paxminer_mapping.py @@ -0,0 +1,217 @@ +import copy +from logging import Logger +from typing import List + +from f3_data_models.models import EventInstance, EventType_x_EventInstance, Org, Org_Type +from f3_data_models.utils import DbManager, get_session +from slack_sdk.models.blocks import ( + DividerBlock, + InputBlock, + SectionBlock, +) +from slack_sdk.models.blocks.basic_components import Option +from slack_sdk.models.blocks.block_elements import ChannelSelectElement, ExternalDataSelectElement, SelectElement +from slack_sdk.web import WebClient +from sqlalchemy import func + +from utilities.database.orm import SlackSettings +from utilities.helper_functions import safe_convert, safe_get +from utilities.slack.sdk_orm import SdkBlockView + +# Action IDs +PAXMINER_ORIGINATING_CHANNEL = "paxminer-originating-channel" +PAXMINER_EVENT_TYPE = "paxminer-event-type" +PAXMINER_AO = "paxminer-assign-ao" +PAXMINER_MAPPING_ID = "paxminer-mapping-id" +PAXMINER_REGION = "paxminer-assign-region" +PAXMINER_CURRENT_MAPPING = "paxminer-current-mapping" + + +def get_paxminer_mapping_text(channel_id: str) -> str: + session = get_session() + print(channel_id) + query = ( + session.query(Org.name, func.count(EventInstance.id)) + .join(EventInstance, EventInstance.org_id == Org.id) + .filter(EventInstance.meta.op("->>")("og_channel") == channel_id) + .group_by(Org.name) + .order_by(func.count(EventInstance.id).desc()) + ) + results = query.all() + if not results: + return "No paxminer import found from this channel. The migration may not have been run yet (check your migration date), or the migration may have been run before we started adding channel metadata. If this is the case, you can request a remigration from the dev team." # noqa: E501 + output = "*Current Mapping:*\n" + mapping_lines = [f"{count} Events -> *{org_name}*" for org_name, count in results] + session.close() + return output + "\n".join(mapping_lines) + + +def get_unmapped_channels_section(region_org_id: int) -> SectionBlock: + session = get_session() + query = ( + session.query(EventInstance.meta.op("->>")("og_channel"), func.count(EventInstance.id)) + .filter( + EventInstance.meta.op("->>")("source") == "paxminer_import", + EventInstance.is_active.is_(True), + EventInstance.org_id == region_org_id, + ) + .group_by(EventInstance.meta.op("->>")("og_channel")) + .order_by(func.count(EventInstance.id).desc()) + ) + results = query.all() + session.close() + if not results: + return SectionBlock( + block_id="paxminer-unmapped-channels", + text="No unmapped data found", # noqa: E501 + ) + unmapped_channels = [] + for channel_id, count in results: + if channel_id: + unmapped_channels.append(f"<#{channel_id}> -> {count} Events") + else: + unmapped_channels.append(f"Unknown Channel -> {count} Events") + return SectionBlock( + block_id="paxminer-unmapped-channels", + text="*Unmapped Channels:*\n" + "\n".join(unmapped_channels), + ) + + +def build_paxminer_mapping_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + if safe_get(body, "actions", 0, "action_id") in [PAXMINER_ORIGINATING_CHANNEL, PAXMINER_REGION]: + initial_channel = safe_get( + body, + "view", + "state", + "values", + PAXMINER_ORIGINATING_CHANNEL, + PAXMINER_ORIGINATING_CHANNEL, + "selected_channel", + ) + print(f"Initial Channel: {initial_channel}") + initial_org = safe_convert( + safe_get(body, "view", "state", "values", PAXMINER_REGION, PAXMINER_REGION, "selected_option", "value"), int + ) + + org_record = DbManager.get(Org, (initial_org or region_record.org_id), joinedloads=[Org.event_types]) + event_type_options = [Option(label=et.name, value=str(et.id)) for et in org_record.event_types] + intial_event_type = next(et for et in event_type_options if et.label == "Bootcamp") + initial_region = {"text": org_record.name, "value": str(org_record.id)} + current_mapping_text = get_paxminer_mapping_text(initial_channel) + ao_records: List[Org] = DbManager.find_records( + Org, + [ + Org.parent_id == (initial_org or region_record.org_id), + Org.org_type == Org_Type.ao, + Org.is_active.is_(True), + ], + ) + form = copy.deepcopy(PAXMINER_MAPPING_FORM) + form.set_options( + { + PAXMINER_AO: [Option(label=ao.name, value=str(ao.id)) for ao in ao_records], + PAXMINER_EVENT_TYPE: event_type_options, + } + ) + form.set_initial_values( + { + PAXMINER_REGION: initial_region, + PAXMINER_EVENT_TYPE: intial_event_type, + PAXMINER_ORIGINATING_CHANNEL: initial_channel, + PAXMINER_CURRENT_MAPPING: current_mapping_text, + } + ) + update_view_id = safe_get(body, "view", "id") + else: + form = copy.deepcopy(PAXMINER_MAPPING_FORM) + form.blocks = form.blocks[:2] # only keep the channel select block + form.blocks.append(get_unmapped_channels_section(region_record.org_id)) + initial_channel = None + update_view_id = None + + if update_view_id: + form.update_modal( + client=client, + view_id=update_view_id, + title_text="Paxminer Data Mapping", + callback_id=PAXMINER_MAPPING_ID, + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Paxminer Data Mapping", + callback_id=PAXMINER_MAPPING_ID, + new_or_add="add", + submit_button_text="None", + ) + + +PAXMINER_MAPPING_FORM = SdkBlockView( + blocks=[ + SectionBlock( + block_id="paxminer-mapping-instructions", + text="Use this tool *after* your paxminer data has been migrated (ie after you migration date). The tool allows you to re-assign imported paxminer events to a different AO, as sometimes the migration script will get it wrong. Notes:\n - Include AOs that starfished prior to your migration, to preserve the historical data with the local AO that generated it\n - Updates to PAX Vault are not instant, please wait up to an hour for these changes to be reflected", # noqa: E501 + ), + InputBlock( + label="Originating Channel", + block_id=PAXMINER_ORIGINATING_CHANNEL, + element=ChannelSelectElement( + action_id=PAXMINER_ORIGINATING_CHANNEL, + ), + dispatch_action=True, + hint="Unarchive the channel if you don't see it in the list.", + ), + DividerBlock(), + SectionBlock( + block_id=PAXMINER_CURRENT_MAPPING, + text="Current mapping", + ), # Placeholder for current mapping text + InputBlock( + label="Assign to Region", + block_id=PAXMINER_REGION, + element=ExternalDataSelectElement( + action_id=PAXMINER_REGION, + placeholder="Select a region...", + ), + optional=False, + dispatch_action=True, + ), + InputBlock( + label="Assign to AO", + block_id=PAXMINER_AO, + element=SelectElement( + action_id=PAXMINER_AO, + ), + optional=False, + ), + InputBlock( + label="Assign to Event Type", + block_id=PAXMINER_EVENT_TYPE, + element=SelectElement( + action_id=PAXMINER_EVENT_TYPE, + ), + optional=False, + ), + ] +) + + +def handle_paxminer_mapping_post( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + data = PAXMINER_MAPPING_FORM.get_selected_values(body) + print(data) + if data.get(PAXMINER_ORIGINATING_CHANNEL) and data.get(PAXMINER_AO) and data.get(PAXMINER_EVENT_TYPE): + DbManager.update_records( + EventInstance, + filters=[EventInstance.meta.op("->>")("og_channel") == data[PAXMINER_ORIGINATING_CHANNEL]], + fields={ + EventInstance.org_id: int(data[PAXMINER_AO]), + EventInstance.event_instances_x_event_types: [ + EventType_x_EventInstance(event_type_id=int(data[PAXMINER_EVENT_TYPE])) + ], + }, + ) diff --git a/apps/slackbot/features/positions.py b/apps/slackbot/features/positions.py new file mode 100644 index 00000000..89920e10 --- /dev/null +++ b/apps/slackbot/features/positions.py @@ -0,0 +1,377 @@ +import copy +import json +from logging import Logger +from typing import List + +from slack_sdk.web import WebClient + +from application.ao import AoData +from application.ao.service import AoService +from application.position import PositionData, PositionWithAssignmentsData +from application.position.service import PositionService +from infrastructure.api_client.ao_repository import get_api_ao_repository +from infrastructure.api_client.position_repository import get_api_position_repository +from utilities.database.orm import SlackSettings +from utilities.helper_functions import SLACK_USERS, get_user, safe_convert, safe_get +from utilities.slack import actions, forms, orm + +# --------------------------------------------------------------------------- +# Composition root +# --------------------------------------------------------------------------- + + +def _build_position_service() -> PositionService: + return PositionService(repository=get_api_position_repository()) + + +def _build_ao_service() -> AoService: + return AoService(repository=get_api_ao_repository()) + + +# --------------------------------------------------------------------------- +# Views +# --------------------------------------------------------------------------- + + +class PositionViews: + @staticmethod + def build_slt_modal( + position_assignments: List[PositionWithAssignmentsData], + aos: List[AoData], + org_id: int, + region_org_id: int, + user_id_to_slack_id: dict, + ) -> orm.BlockView: + level_options = [orm.SelectorOption(name="Region", value="0")] + for a in aos: + level_options.append(orm.SelectorOption(name=a.name, value=str(a.id))) + + blocks = [ + orm.InputBlock( + label="Select the SLT positions for...", + action=actions.SLT_LEVEL_SELECT, + element=orm.StaticSelectElement( + options=level_options, + initial_value="0" if org_id == region_org_id else str(org_id), + ), + dispatch_action=True, + ), + ] + + for p in position_assignments: + slack_user_ids = [user_id_to_slack_id[u.user_id] for u in p.users if u.user_id in user_id_to_slack_id] + block = orm.InputBlock( + label=p.name, + action=actions.SLT_SELECT + str(p.id) + "_" + str(org_id), + optional=True, + element=orm.MultiUsersSelectElement( + placeholder="Select SLT Members...", + ), + hint=p.description, + ) + if slack_user_ids: + block.element.initial_value = slack_user_ids + blocks.append(block) + + blocks.append( + orm.ActionsBlock( + elements=[ + orm.ButtonElement( + label=":heavy_plus_sign: New Position", + action=actions.CONFIG_NEW_POSITION, + ), + orm.ButtonElement( + label=":pencil2: Edit Positions", + action=actions.CONFIG_EDIT_POSITIONS, + ), + ] + ) + ) + + return orm.BlockView(blocks=blocks) + + @staticmethod + def build_position_list_modal(positions: List[PositionData]) -> orm.BlockView: + blocks = [ + orm.ContextBlock( + element=orm.ContextElement( + initial_value="Only region-specific positions can be edited or deleted.", + ), + ) + ] + + if not positions: + blocks.append( + orm.SectionBlock( + label="No custom positions found. Use 'New Position' to create one.", + ) + ) + else: + for p in positions: + blocks.append( + orm.SectionBlock( + label=p.name, + action=f"{actions.POSITION_EDIT_DELETE}_{p.id}", + element=orm.StaticSelectElement( + placeholder="Edit or Delete", + options=orm.as_selector_options(names=["Edit", "Delete"]), + confirm=orm.ConfirmObject( + title="Are you sure?", + text="Are you sure you want to edit / delete this Position? This cannot be undone.", + confirm="Yes, I am sure", + deny="Whups, never mind", + ), + ), + ) + ) + + return orm.BlockView(blocks=blocks) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _user_id_to_slack_id_map(team_id: str) -> dict: + if not SLACK_USERS: + from utilities.helper_functions import update_local_slack_users + + update_local_slack_users() + return {su.user_id: su.slack_id for su in SLACK_USERS.values() if su.slack_team_id == team_id} + + +# --------------------------------------------------------------------------- +# Handler functions +# --------------------------------------------------------------------------- + + +def build_config_slt_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + update_view_id: str = None, + selected_org_id: int = None, +): + if safe_get(body, "actions", 0, "action_id") == actions.SLT_LEVEL_SELECT: + org_id = safe_convert(safe_get(body, "actions", 0, "selected_option", "value"), int) + org_id = org_id if org_id != 0 else region_record.org_id + update_view_id = safe_get(body, "view", "id") + elif update_view_id is None: + update_view_id = safe_get(body, actions.LOADING_ID) + org_id = selected_org_id or region_record.org_id + else: + org_id = selected_org_id or region_record.org_id + + service = _build_position_service() + ao_service = _build_ao_service() + position_assignments = service.get_positions_with_assignments(org_id, region_record.org_id) + + aos = ao_service.get_region_aos(region_record.org_id) + + user_id_to_slack_id = _user_id_to_slack_id_map(region_record.team_id) + + form = PositionViews.build_slt_modal( + position_assignments=position_assignments, + aos=aos, + org_id=org_id, + region_org_id=region_record.org_id, + user_id_to_slack_id=user_id_to_slack_id, + ) + + if update_view_id: + form.update_modal( + client=client, + view_id=update_view_id, + callback_id=actions.CONFIG_SLT_CALLBACK_ID, + title_text="SLT Members", + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + callback_id=actions.CONFIG_SLT_CALLBACK_ID, + title_text="SLT Members", + new_or_add="add", + ) + + +def build_new_position_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + form = copy.deepcopy(forms.CONFIG_NEW_POSITION_FORM) + selected_org_id = safe_convert( + safe_get( + body, + "view", + "state", + "values", + actions.SLT_LEVEL_SELECT, + actions.SLT_LEVEL_SELECT, + "selected_option", + "value", + ), + int, + ) + selected_org_id = selected_org_id if selected_org_id != 0 else region_record.org_id + + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + callback_id=actions.NEW_POSITION_CALLBACK_ID, + title_text="New Position", + new_or_add="add", + parent_metadata={"org_id": selected_org_id}, + ) + + +def handle_new_position_post( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + form_data = forms.CONFIG_NEW_POSITION_FORM.get_selected_values(body) + metadata = json.loads(safe_get(body, "view", "private_metadata") or "{}") + org_id = metadata.get("org_id") or region_record.org_id + org_type = "region" if org_id == region_record.org_id else "ao" + + service = _build_position_service() + service.create_position( + name=safe_get(form_data, actions.CONFIG_NEW_POSITION_NAME), + description=safe_get(form_data, actions.CONFIG_NEW_POSITION_DESCRIPTION), + org_id=region_record.org_id, + org_type=org_type, + ) + + build_config_slt_form( + body, + client, + logger, + context, + region_record, + update_view_id=safe_get(body, "view", "previous_view_id"), + selected_org_id=org_id, + ) + + +def handle_config_slt_post(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + form_data = body["view"]["state"]["values"] + org_assignments: dict = {} + + for key, value in form_data.items(): + if key.startswith(actions.SLT_SELECT): + position_id, org_id = map(int, key.replace(actions.SLT_SELECT, "").split("_")) + org_id = org_id if org_id != 0 else region_record.org_id + slack_user_ids = value[key].get("selected_users", []) + users = [get_user(u, region_record, client, logger) for u in slack_user_ids] + user_ids = [u.user_id for u in users if u] + + if org_id not in org_assignments: + org_assignments[org_id] = {} + org_assignments[org_id][position_id] = user_ids + + service = _build_position_service() + for org_id, position_map in org_assignments.items(): + assignments = [{"positionId": pos_id, "userIds": uid_list} for pos_id, uid_list in position_map.items()] + service.update_org_assignments(org_id=org_id, assignments=assignments) + + +def build_position_list_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + update_view_id: str = None, +): + service = _build_position_service() + positions = service.get_org_positions(region_record.org_id) + + form = PositionViews.build_position_list_modal(positions) + + if update_view_id: + form.update_modal( + client=client, + view_id=update_view_id, + callback_id=actions.EDIT_DELETE_POSITION_CALLBACK_ID, + title_text="Edit/Delete Positions", + submit_button_text="None", + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Edit/Delete Positions", + callback_id=actions.EDIT_DELETE_POSITION_CALLBACK_ID, + submit_button_text="None", + new_or_add="add", + ) + + +def handle_position_edit_delete( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + action = safe_get(body, "actions", 0, "selected_option", "value") + position_id = int(safe_get(body, "actions", 0, "action_id").split("_")[-1]) + + service = _build_position_service() + + if action == "Edit": + position = service.get_by_id(position_id) + if position: + build_edit_position_form(body, client, logger, context, region_record, position) + elif action == "Delete": + service.delete_position(position_id) + build_position_list_form( + body, client, logger, context, region_record, update_view_id=safe_get(body, "view", "id") + ) + + +def build_edit_position_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, + position: PositionData, +): + form = copy.deepcopy(forms.CONFIG_NEW_POSITION_FORM) + + form.set_initial_values( + { + actions.CONFIG_NEW_POSITION_NAME: position.name, + actions.CONFIG_NEW_POSITION_DESCRIPTION: position.description or "", + } + ) + + form.update_modal( + client=client, + view_id=safe_get(body, "view", "id"), + callback_id=actions.EDIT_POSITION_CALLBACK_ID, + title_text="Edit Position", + parent_metadata={"position_id": position.id}, + ) + + +def handle_edit_position_post( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + form_data = forms.CONFIG_NEW_POSITION_FORM.get_selected_values(body) + metadata = json.loads(safe_get(body, "view", "private_metadata") or "{}") + position_id = metadata.get("position_id") + + if position_id: + service = _build_position_service() + service.update_position( + position_id=position_id, + name=safe_get(form_data, actions.CONFIG_NEW_POSITION_NAME), + description=safe_get(form_data, actions.CONFIG_NEW_POSITION_DESCRIPTION), + ) + + build_config_slt_form( + body, + client, + logger, + context, + region_record, + update_view_id=safe_get(body, "view", "previous_view_id"), + selected_org_id=metadata.get("org_id"), + ) diff --git a/apps/slackbot/features/region.py b/apps/slackbot/features/region.py new file mode 100644 index 00000000..4f0b1fcf --- /dev/null +++ b/apps/slackbot/features/region.py @@ -0,0 +1,230 @@ +import copy +import re +from logging import Logger + +import requests +from f3_data_models.models import Org, Role, Role_x_User_x_Org, SlackUser +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient + +from features import connect +from utilities.database.orm import SlackSettings +from utilities.database.special_queries import get_admin_users +from utilities.helper_functions import get_user, safe_get, upload_files_to_storage +from utilities.slack import actions, orm + + +def build_region_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + form = copy.deepcopy(REGION_FORM) + org_record: Org = DbManager.get(Org, region_record.org_id) + org_meta = org_record.meta if org_record else {} + + if not org_record: + connect.build_connect_options_form(body, client, logger, context) + else: + # Get current admin users, only those with slack ids + admin_users = get_admin_users(region_record.org_id, slack_team_id=region_record.team_id) + admin_user_ids = [u[1].slack_id for u in admin_users if safe_get(u[1], "slack_id")] + print("Admin user ids:", admin_user_ids) + print("Admin users:", admin_users) + + if safe_get(org_meta, actions.REGION_DEFAULT_PV_FILTERS): + full_pv_filter_url = f"https://pax-vault.f3nation.com/stats/region/{org_record.id}?{org_meta[actions.REGION_DEFAULT_PV_FILTERS]}" + else: + full_pv_filter_url = f"https://pax-vault.f3nation.com/stats/region/{org_record.id}" + + form.set_initial_values( + { + actions.REGION_NAME: org_record.name, + actions.REGION_DESCRIPTION: org_record.description, + actions.REGION_LOGO: org_record.logo_url, + actions.REGION_DEFAULT_PV_FILTERS: full_pv_filter_url, + actions.REGION_WEBSITE: org_record.website, + actions.REGION_EMAIL: org_record.email, + actions.REGION_TWITTER: org_record.twitter, + actions.REGION_FACEBOOK: org_record.facebook, + actions.REGION_INSTAGRAM: org_record.instagram, + actions.REGION_ADMINS: admin_user_ids, + } + ) + + if org_record.logo_url: + try: + if requests.head(org_record.logo_url).status_code == 200: + form.blocks.insert(2, orm.ImageBlock(image_url=org_record.logo_url, alt_text="Region Logo")) + except requests.RequestException as e: + logger.error(f"Error fetching region logo: {e}") + + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Edit Region", + callback_id=actions.REGION_CALLBACK_ID, + new_or_add="add", + ) + + +def handle_region_edit(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + form_data = REGION_FORM.get_selected_values(body) + + file = safe_get(form_data, actions.REGION_LOGO, 0) + if file: + file_list, file_send_list, file_ids, low_rez_file_ids = upload_files_to_storage( + files=[file], + logger=logger, + client=client, + enforce_square=True, + max_height=512, + bucket_name="org-logos", + file_name=str(region_record.org_id), + enforce_png=True, + ) + logo_url = file_list[0] + else: + logo_url = None + + email = safe_get(form_data, actions.REGION_EMAIL) + if email and not re.match(r"[^@]+@[^@]+\.[^@]+", email): + email = None + + website = safe_get(form_data, actions.REGION_WEBSITE) or "" + if not re.match( + r"https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)", website + ): + website = None + + default_pv_filters = safe_get(form_data, actions.REGION_DEFAULT_PV_FILTERS) + if default_pv_filters and not re.match( + r"https?://pax-vault\.f3nation\.com/stats/region/\d+\?.+", default_pv_filters + ): + default_pv_filters = None + else: + default_pv_filters = default_pv_filters.split("?")[1] if default_pv_filters else None + + fields = { + Org.name: safe_get(form_data, actions.REGION_NAME), + Org.description: safe_get(form_data, actions.REGION_DESCRIPTION), + Org.website: website, + Org.email: email, + Org.twitter: safe_get(form_data, actions.REGION_TWITTER), + Org.facebook: safe_get(form_data, actions.REGION_FACEBOOK), + Org.instagram: safe_get(form_data, actions.REGION_INSTAGRAM), + } + if logo_url: + fields[Org.logo_url] = logo_url + if default_pv_filters: + org_record = DbManager.get(Org, region_record.org_id) + org_meta = org_record.meta if org_record else {} + org_meta[actions.REGION_DEFAULT_PV_FILTERS] = default_pv_filters + fields[Org.meta] = org_meta + + DbManager.update_record(Org, region_record.org_id, fields) + + admin_users_slack = safe_get(form_data, actions.REGION_ADMINS) + admin_users: list[SlackUser] = [get_user(user_id, region_record, client, logger) for user_id in admin_users_slack] + admin_user_ids = [u.user_id for u in admin_users] + admin_role_id = DbManager.find_first_record(Role, filters=[Role.name == "admin"]).id + admin_records = [ + Role_x_User_x_Org( + role_id=admin_role_id, + org_id=region_record.org_id, + user_id=user_id, + ) + for user_id in admin_user_ids + if user_id + ] + + # pull existing admin users, only delete those that have slack users (other admins should be managed in maps) + existing_admin_users = get_admin_users(region_record.org_id, slack_team_id=region_record.team_id) + existing_admin_users_ids = [u[1].user_id for u in existing_admin_users if safe_get(u[1], "user_id")] + DbManager.delete_records( + Role_x_User_x_Org, + filters=[ + Role_x_User_x_Org.org_id == region_record.org_id, + Role_x_User_x_Org.role_id == admin_role_id, + Role_x_User_x_Org.user_id.in_(existing_admin_users_ids), + ], + ) + DbManager.create_or_ignore(Role_x_User_x_Org, admin_records) + + +REGION_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Region Title", + action=actions.REGION_NAME, + element=orm.PlainTextInputElement(placeholder="Enter the Region name"), + optional=False, + ), + orm.InputBlock( + label="Region Description", + action=actions.REGION_DESCRIPTION, + element=orm.PlainTextInputElement(placeholder="Enter a description for the Region", multiline=True), + optional=True, + ), + orm.InputBlock( + label="Region Logo", + action=actions.REGION_LOGO, + optional=True, + element=orm.FileInputElement( + max_files=1, + filetypes=[ + "png", + "jpg", + "heic", + "bmp", + ], + ), + ), + orm.InputBlock( + label="Region Admins", + action=actions.REGION_ADMINS, + element=orm.MultiUsersSelectElement(placeholder="Select the Region admins"), + hint="These users will have admin permissions for the Region (modify schedules, backblasts, etc.)", + optional=False, + ), + orm.InputBlock( + label="Default PAX Vault Filters", + action=actions.REGION_DEFAULT_PV_FILTERS, + element=orm.PlainTextInputElement(), + hint="Apply the filters you want defaulted in PV, then paste the URL here", + optional=True, + ), + orm.InputBlock( + label="Region Website", + action=actions.REGION_WEBSITE, + element=orm.PlainTextInputElement(placeholder="Enter the Region website"), + optional=True, + ), + orm.InputBlock( + label="Region email", + action=actions.REGION_EMAIL, + element=orm.EmailInputElement(placeholder="Enter the Region email"), + optional=True, + ), + orm.InputBlock( + label="Region Twitter", + action=actions.REGION_TWITTER, + element=orm.PlainTextInputElement(placeholder="Enter the Region Twitter"), + optional=True, + ), + orm.InputBlock( + label="Region Facebook", + action=actions.REGION_FACEBOOK, + element=orm.PlainTextInputElement(placeholder="Enter the Region Facebook"), + optional=True, + ), + orm.InputBlock( + label="Region Instagram", + action=actions.REGION_INSTAGRAM, + element=orm.PlainTextInputElement(placeholder="Enter the Region Instagram"), + optional=True, + ), + ] +) diff --git a/apps/slackbot/features/reporting.py b/apps/slackbot/features/reporting.py new file mode 100644 index 00000000..187e4a24 --- /dev/null +++ b/apps/slackbot/features/reporting.py @@ -0,0 +1,114 @@ +import copy +from logging import Logger + +from f3_data_models.models import SlackSpace +from f3_data_models.utils import DbManager +from slack_sdk.models import blocks +from slack_sdk.web import WebClient + +from utilities.database.orm import SlackSettings +from utilities.helper_functions import safe_get +from utilities.slack.sdk_orm import SdkBlockView, as_selector_options + +MONTHLY_REPORTS_ENABLED = "monthly_reports_enabled" +REGION_REPORTING_CHANNEL = "region_reporting_channel" +REPORTING_CALLBACK_ID = "reporting_settings" +MONTHLY_REPORT_OPTIONS = { + "monthly_summary": "Region Monthly Summary", + # "region_leaderboard": "Region Leaderboard", + "ao_monthly_summary": "AO Monthly Summary", + # "ao_leaderboard": "AO Leaderboard", +} +RUN_MONTHLY_REPORTS_NOW = "run_monthly_reports_now" + + +def build_reporting_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + form = copy.deepcopy(FORM) + + monthly_options = [] + if region_record.reporting_region_monthly_summary_enabled: + monthly_options.append("monthly_summary") + # if region_record.reporting_region_leaderboard_enabled: + # monthly_options.append("region_leaderboard") + if region_record.reporting_ao_monthly_summary_enabled: + monthly_options.append("ao_monthly_summary") + # if region_record.reporting_ao_leaderboard_enabled: + # monthly_options.append("ao_leaderboard") + + form.set_initial_values( + { + MONTHLY_REPORTS_ENABLED: monthly_options, + REGION_REPORTING_CHANNEL: region_record.reporting_region_channel, + } + ) + + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Reporting Settings", + callback_id=REPORTING_CALLBACK_ID, + new_or_add="add", + ) + + +def handle_reporting_edit(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + form_data = FORM.get_selected_values(body) + + selected_reports = form_data.get(MONTHLY_REPORTS_ENABLED) or [] + region_record.reporting_region_monthly_summary_enabled = "monthly_summary" in selected_reports + # region_record.reporting_region_leaderboard_enabled = "region_leaderboard" in selected_reports + region_record.reporting_ao_monthly_summary_enabled = "ao_monthly_summary" in selected_reports + # region_record.reporting_ao_leaderboard_enabled = "ao_leaderboard" in selected_reports + region_record.reporting_region_channel = form_data.get(REGION_REPORTING_CHANNEL) + + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + + +FORM = SdkBlockView( + blocks=[ + blocks.HeaderBlock(text="Monthly Report Settings"), + blocks.InputBlock( + label="Reports Enabled", + element=blocks.CheckboxesElement( + action_id=MONTHLY_REPORTS_ENABLED, + options=as_selector_options( + names=list(MONTHLY_REPORT_OPTIONS.values()), values=list(MONTHLY_REPORT_OPTIONS.keys()) + ), + ), + optional=True, + block_id=MONTHLY_REPORTS_ENABLED, + ), + # blocks.ActionsBlock( + # elements=[ + # blocks.ButtonElement( + # text="Run Monthly Reports Now", action_id=RUN_MONTHLY_REPORTS_NOW, style="primary" + # ), + # ], + # ), + blocks.ContextBlock( + elements=[ + blocks.MarkdownTextObject(text="Monthly reports are automatically sent on the 2nd of each month.") + ] + ), + blocks.InputBlock( + label="Region Reporting Channel", + element=blocks.ChannelSelectElement( + action_id=REGION_REPORTING_CHANNEL, + placeholder="Select a channel", + ), + optional=True, + block_id=REGION_REPORTING_CHANNEL, + hint="Must be selected for reports to be sent", + ), + ] +) diff --git a/apps/slackbot/features/special_events.py b/apps/slackbot/features/special_events.py new file mode 100644 index 00000000..de1d042c --- /dev/null +++ b/apps/slackbot/features/special_events.py @@ -0,0 +1,91 @@ +import copy +from logging import Logger + +from f3_data_models.models import SlackSpace +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient + +from utilities.database.orm import SlackSettings +from utilities.helper_functions import safe_convert, safe_get, update_local_region_records +from utilities.slack import actions, orm + + +def build_special_settings_form( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + form = copy.deepcopy(SPECIAL_EVENTS_FORM) + form.set_initial_values( + { + actions.SPECIAL_EVENTS_ENABLED: "enable" if region_record.special_events_enabled else None, + # actions.SPECIAL_EVENTS_CHANNEL: region_record.special_events_channel, + actions.SPECIAL_EVENTS_POST_DAYS: str(region_record.special_events_post_days or 30), + actions.SPECIAL_EVENTS_INFO_CANVAS_CHANNEL: region_record.canvas_channel, + } + ) + + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="Edit Region", + callback_id=actions.SPECIAL_EVENTS_CALLBACK_ID, + new_or_add="add", + ) + + +def handle_special_settings_edit( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + form_data = SPECIAL_EVENTS_FORM.get_selected_values(body) + + region_record.special_events_enabled = safe_get(form_data, actions.SPECIAL_EVENTS_ENABLED, 0) == "enable" + # region_record.special_events_channel = safe_get(form_data, actions.SPECIAL_EVENTS_CHANNEL) + region_record.special_events_post_days = safe_convert(safe_get(form_data, actions.SPECIAL_EVENTS_POST_DAYS), int) + region_record.canvas_channel = safe_get(form_data, actions.SPECIAL_EVENTS_INFO_CANVAS_CHANNEL) + + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + + update_local_region_records() + + +SPECIAL_EVENTS_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Enable Region Info Canvas", + action=actions.SPECIAL_EVENTS_ENABLED, + element=orm.CheckboxInputElement(options=orm.as_selector_options(["Enable"], ["enable"])), + optional=False, + ), + # orm.InputBlock( + # label="Special Events Channel", + # action=actions.SPECIAL_EVENTS_CHANNEL, + # element=orm.ConversationsSelectElement(), + # optional=True, + # ), + orm.InputBlock( + label="How far ahead should events be list?", + action=actions.SPECIAL_EVENTS_POST_DAYS, + element=orm.PlainTextInputElement(placeholder="Enter the number of days"), + optional=True, + hint="This is the number of days before the event that special events will be list on the canvas. Defaults to 30 days.", # noqa + ), + orm.InputBlock( + label="Region Info Canvas Channel", + action=actions.SPECIAL_EVENTS_INFO_CANVAS_CHANNEL, + element=orm.ConversationsSelectElement(), + optional=True, + hint="This is the channel where the region info canvas will be posted.", + ), + ] +) diff --git a/apps/slackbot/features/strava.py b/apps/slackbot/features/strava.py new file mode 100644 index 00000000..b31c3b6f --- /dev/null +++ b/apps/slackbot/features/strava.py @@ -0,0 +1,424 @@ +import copy +import json +import os +from datetime import datetime +from logging import Logger +from typing import Any, Dict, List + +import requests +from f3_data_models.models import SlackUser, User +from f3_data_models.utils import DbManager +from flask import Request, Response +from requests_oauthlib import OAuth2Session +from slack_sdk import WebClient + +from utilities import constants +from utilities.database.orm import SlackSettings +from utilities.helper_functions import parse_rich_block, replace_user_channel_ids, safe_get +from utilities.slack import actions, forms +from utilities.slack import orm as slack_orm + + +def build_strava_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + user_id = safe_get(body, "user_id") or safe_get(body, "user", "id") + team_id = safe_get(body, "team_id") or safe_get(body, "team", "id") + channel_id = safe_get(body, "channel_id") or safe_get(body, "channel", "id") + + backblast_ts = body["message"]["ts"] + backblast_meta = safe_get(body, "message", "metadata", "event_payload") or json.loads( + safe_get(body, "message", "blocks", -1, "elements", 0, "value") or "{}" + ) + moleskine = body["message"]["blocks"][1] + moleskine_text = replace_user_channel_ids(parse_rich_block(moleskine), region_record, client, logger) + if "COT:" in moleskine_text: + moleskine_text = moleskine_text.split("COT:")[0] + elif "Announcements" in moleskine_text: + moleskine_text = moleskine_text.split("Announcements")[0] + + # allow_strava: bool = ( + # (user_id == backblast_meta[actions.BACKBLAST_Q]) + # or (user_id in (backblast_meta[actions.BACKBLAST_COQ] or [])) + # or (user_id in (backblast_meta[actions.BACKBLAST_PAX] or [])) + # or (user_id in (backblast_meta[actions.BACKBLAST_OP] or [])) + # ) + allow_strava = True + + APP_URL = os.environ.get("APP_URL", "") + if os.environ.get(constants.STRAVA_CLIENT_ID) and os.environ.get(constants.STRAVA_CLIENT_SECRET): + oauth = OAuth2Session( + client_id=os.environ[constants.STRAVA_CLIENT_ID], + redirect_uri=f"{APP_URL}/exchange_token", + scope=["read,activity:read,activity:write"], + state=f"{team_id}-{user_id}", + ) + authorization_url, state = oauth.authorization_url("https://www.strava.com/oauth/authorize") + auth_blocks = [ + slack_orm.ImageBlock( + image_url="https://slackblast-images.s3.amazonaws.com/btn_strava_connectwith_orange.png", + alt_text="Connect with Strava", + ), + slack_orm.ActionsBlock( + elements=[ + slack_orm.ButtonElement( + label="Connect", + action=actions.STRAVA_CONNECT_BUTTON, + url=authorization_url, + ) + ] + ), + slack_orm.ContextBlock( + element=slack_orm.ContextElement( + initial_value="Opens in a new window", + ), + action="context", + ), + ] + else: + auth_blocks = [ + slack_orm.SectionBlock( + label="Strava client ID and secret are not configured.", # noqa + ) + ] + + if allow_strava: + update_view_id = safe_get(body, actions.LOADING_ID) + user_records: List[SlackUser] = DbManager.find_records( + SlackUser, filters=[SlackUser.slack_id == user_id, SlackUser.slack_team_id == team_id] + ) + + if len(user_records) == 0: + title_text = "Connect Strava" + strava_blocks = auth_blocks + else: + title_text = "Choose Activity" + slack_user_record = user_records[0] + user: User = DbManager.get(User, slack_user_record.user_id) + strava_recent_activities = get_strava_activities(user) if user else [] + + logger.info(f"recent activities found: {strava_recent_activities}") + if len(strava_recent_activities) == 0: + strava_blocks = [ + slack_orm.SectionBlock( + label="No recent activities found. Please log an activity on Strava first. If you have logged activities, it's possible that your access token may be expired. Please use the button below to reconnect to Strava.", # noqa + ), + *auth_blocks, + ] + + button_elements = [] + for activity in strava_recent_activities: + date = datetime.strptime(activity["start_date_local"], "%Y-%m-%dT%H:%M:%SZ") + date_fmt = date.strftime("%m-%d %H:%M") + button_elements.append( + slack_orm.ButtonElement( + label=f"{date_fmt} - {activity['name']}"[:75], + action="-".join([actions.STRAVA_ACTIVITY_BUTTON, str(activity["id"])]), + value=json.dumps( + { + actions.STRAVA_ACTIVITY_ID: activity["id"], + actions.STRAVA_CHANNEL_ID: channel_id, + actions.STRAVA_BACKBLAST_TS: backblast_ts, + actions.STRAVA_BACKBLAST_TITLE: backblast_meta["title"], + # actions.STRAVA_BACKBLAST_MOLESKINE: moleskine_text[:1500], + } + ), + # TODO: add confirmation modal + ) + ) + strava_blocks = [slack_orm.ActionsBlock(elements=button_elements)] + + strava_form = slack_orm.BlockView(blocks=strava_blocks) + + strava_form.update_modal( + client=client, + view_id=update_view_id, + callback_id=actions.STRAVA_CALLBACK_ID, + title_text=title_text, + submit_button_text="None", + parent_metadata={actions.STRAVA_BACKBLAST_MOLESKINE: moleskine_text[:2500]}, + ) + else: + client.chat_postEphemeral( + text="Connecting Strava to this Slackblast is only allowed for the tagged PAX." + "Please contact one of them to make changes.", + channel=channel_id, + user=user_id, + ) + + +def build_strava_modify_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + strava_metadata = json.loads(safe_get(body, "actions", 0, "value") or "{}") + private_metadata = json.loads(safe_get(body, "view", "private_metadata") or "{}") + strava_activity_id = strava_metadata[actions.STRAVA_ACTIVITY_ID] + channel_id = strava_metadata[actions.STRAVA_CHANNEL_ID] + backblast_ts = strava_metadata[actions.STRAVA_BACKBLAST_TS] + backblast_title = strava_metadata[actions.STRAVA_BACKBLAST_TITLE] + backblast_moleskine = private_metadata[actions.STRAVA_BACKBLAST_MOLESKINE] + + view_id = safe_get(body, "container", "view_id") + backblast_metadata = { + "strava_activity_id": strava_activity_id, + "channel_id": channel_id, + "backblast_ts": backblast_ts, + } + + activity_description = backblast_moleskine.replace("*", "") + # remove all text after `COT:` or `COT :` if it exists + if "COT:" in activity_description: + activity_description = activity_description.split("COT:")[0] + activity_description += "\n\nLearn more about F3 at https://f3nation.com" + + modify_form = copy.deepcopy(forms.STRAVA_ACTIVITY_MODIFY_FORM) + modify_form.set_initial_values( + { + actions.STRAVA_ACTIVITY_TITLE: backblast_title, + actions.STRAVA_ACTIVITY_DESCRIPTION: activity_description, + } + ) + + modify_form.update_modal( + client=client, + view_id=view_id, + title_text="Modify Strava Activity", + callback_id=actions.STRAVA_MODIFY_CALLBACK_ID, + parent_metadata=backblast_metadata, + submit_button_text="Modify Strava activity", + close_button_text="Close without modifying", + notify_on_close=True, + ) + + +def strava_exchange_token(request: Request) -> dict: + """Exchanges a Strava auth code for an access token.""" + team_id, user_id = request.args.get("state").split("-") + code = request.args.get("code") + if not code: + r = { + "statusCode": 400, + "body": {"error": "No code provided."}, + "headers": {}, + } + return r + + if not os.environ.get(constants.STRAVA_CLIENT_ID) or not os.environ.get(constants.STRAVA_CLIENT_SECRET): + r = { + "statusCode": 500, + "body": {"error": "Strava client ID or secret not configured."}, + "headers": {}, + } + return r + else: + response = requests.post( + url="https://www.strava.com/oauth/token", + data={ + "client_id": os.environ[constants.STRAVA_CLIENT_ID], + "client_secret": os.environ[constants.STRAVA_CLIENT_SECRET], + "code": code, + "grant_type": "authorization_code", + }, + ) + response.raise_for_status() + + response_json = response.json() + + slack_user_records: List[SlackUser] = DbManager.find_records( + SlackUser, filters=[SlackUser.slack_id == user_id, SlackUser.slack_team_id == team_id] + ) + msg = "Authorization unsuccessful. Please try again or contact support if the issue persists." + if slack_user_records: + user_id = safe_get(slack_user_records, 0, "user_id") + if user_id: + user: User = DbManager.get(User, user_id) + if user: + user_meta = user.meta or {} + user_meta["strava_athlete_id"] = response_json["athlete"]["id"] + user_meta["strava_access_token"] = response_json["access_token"] + user_meta["strava_refresh_token"] = response_json["refresh_token"] + user_meta["strava_expires_at"] = response_json["expires_at"] + DbManager.update_record(User, user_id, {User.meta: user_meta}) + msg = "Authorization successful! You can return to Slack." + + r = { + "statusCode": 200, + "body": {"message": msg}, + "headers": {}, + } + + return Response( + json.dumps(r["body"]), + status=r["statusCode"], + headers=r["headers"], + content_type="application/json", + ) + + +def check_and_refresh_strava_token(user: User) -> str: + """Check if a Strava token is expired and refresh it if necessary.""" + user_meta = user.meta or {} + access_token = user_meta.get("strava_access_token") + if not access_token: + return None + + expires_at = user_meta.get("strava_expires_at", 0) + if ( + expires_at < datetime.now().timestamp() + and os.environ.get(constants.STRAVA_CLIENT_ID) + and os.environ.get(constants.STRAVA_CLIENT_SECRET) + ): + request_url = "https://www.strava.com/api/v3/oauth/token" + res = requests.post( + request_url, + data={ + "client_id": os.environ["STRAVA_CLIENT_ID"], + "client_secret": os.environ["STRAVA_CLIENT_SECRET"], + "refresh_token": user_meta.get("strava_refresh_token"), + "grant_type": "refresh_token", + }, + ) + res.raise_for_status() + data = res.json() + access_token = data["access_token"] + user_meta["strava_access_token"] = data["access_token"] + user_meta["strava_refresh_token"] = data["refresh_token"] + user_meta["strava_expires_at"] = data["expires_at"] + DbManager.update_record(User, user.id, {User.meta: user_meta}) + + return access_token + + +def get_strava_activities(user: User) -> List[Dict]: + """Get a list of Strava activities for a user.""" + user_meta = user.meta or {} + if not user_meta.get("strava_access_token"): + return [] + + access_token = check_and_refresh_strava_token(user) + request_url = "https://www.strava.com/api/v3/athlete/activities" + res = requests.get(request_url, headers={"Authorization": f"Bearer {access_token}"}, params={"per_page": 10}) + res.raise_for_status() + data = res.json() + return data + + +def update_strava_activity( + strava_activity_id: str, + user_id: str, + team_id: str, + backblast_title: str, + backblast_moleskine: str, +) -> Dict[str, Any]: + """Update a Strava activity. + + Args: + strava_activity_id (str): Strava activity ID + user_id (str): Slack user ID + team_id (str): Slack team ID + backblast_title (str): Backblast title (used for updating activity name) + backblast_moleskine (str): Backblast Moleskine (used for updating activity description) + + Returns: + dict: Updated Strava activity data + """ + slack_user_records: List[SlackUser] = DbManager.find_records( + SlackUser, filters=[SlackUser.slack_id == user_id, SlackUser.slack_team_id == team_id] + ) + slack_user_record = slack_user_records[0] + user: User = DbManager.get(User, slack_user_record.user_id) + + access_token = check_and_refresh_strava_token(user) + request_url = f"https://www.strava.com/api/v3/activities/{strava_activity_id}" + res = requests.put( + request_url, + headers={"Authorization": f"Bearer {access_token}"}, + json={ + "name": backblast_title, + "description": backblast_moleskine, + }, + # data={ + # "name": backblast_title, + # "description": backblast_moleskine, + # }, + ) + res.raise_for_status() + data = res.json() + return data + + +def get_strava_activity( + strava_activity_id: str, + user_id: str, + team_id: str, +) -> Dict[str, Any]: + """Get a Strava activity. + + Args: + strava_activity_id (str): Strava activity ID + + Returns: + dict: Strava activity data + """ + slack_user_records: List[SlackUser] = DbManager.find_records( + SlackUser, filters=[SlackUser.slack_id == user_id, SlackUser.slack_team_id == team_id] + ) + slack_user_record = slack_user_records[0] + user: User = DbManager.get(User, slack_user_record.user_id) + + access_token = check_and_refresh_strava_token(user) + + request_url = f"https://www.strava.com/api/v3/activities/{strava_activity_id}" + res = requests.get( + request_url, + headers={"Authorization": f"Bearer {access_token}"}, + ) + res.raise_for_status() + data = res.json() + return data + + +def handle_strava_modify(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + strava_data: dict = forms.STRAVA_ACTIVITY_MODIFY_FORM.get_selected_values(body) + event_type = safe_get(body, "type") + metadata = json.loads(body["view"]["private_metadata"]) + strava_activity_id = metadata["strava_activity_id"] + channel_id = metadata["channel_id"] + backblast_ts = metadata["backblast_ts"] + user_id = safe_get(body, "user_id") or safe_get(body, "user", "id") + team_id = safe_get(body, "team_id") or safe_get(body, "team", "id") + + if (event_type != "view_closed") and strava_data: + activity_data = update_strava_activity( + strava_activity_id=strava_activity_id, + user_id=user_id, + team_id=team_id, + backblast_title=strava_data[actions.STRAVA_ACTIVITY_TITLE], + backblast_moleskine=strava_data[actions.STRAVA_ACTIVITY_DESCRIPTION], + ) + else: + activity_data = get_strava_activity(strava_activity_id=strava_activity_id, user_id=user_id, team_id=team_id) + + msg = f"<@{user_id}> has connected this backblast to a Strava activity ()!" # noqa + if (safe_get(activity_data, "calories") is not None) & (safe_get(activity_data, "distance") is not None): + msg += f" He traveled {round(activity_data['distance'] * 0.00062137, 1)} miles :runner: and burned " + msg += f"{activity_data['calories']} calories :fire:." + elif safe_get(activity_data, "calories"): + msg += f" He burned {activity_data['calories']} calories :fire:." + elif safe_get(activity_data, "distance"): + msg += f" He traveled {round(activity_data['distance'] * 0.00062137, 1)} miles :runner:." + + blocks = [ + slack_orm.SectionBlock( + label=msg, + ).as_form_field(), + slack_orm.ImageBlock( + image_url="https://slackblast-images.s3.amazonaws.com/api_logo_pwrdBy_strava_stack_light.png", + alt_text="Powered by Strava", + ).as_form_field(), + ] + + client.chat_postMessage( + channel=channel_id, + thread_ts=backblast_ts, + text=msg, + blocks=blocks, + ) diff --git a/apps/slackbot/features/user.py b/apps/slackbot/features/user.py new file mode 100644 index 00000000..eba30f9c --- /dev/null +++ b/apps/slackbot/features/user.py @@ -0,0 +1,287 @@ +import copy +import os +from logging import Logger + +from f3_data_models.models import SlackUser, User +from f3_data_models.utils import DbManager +from slack_sdk import WebClient + +from utilities.database.orm import SlackSettings +from utilities.helper_functions import get_user, safe_convert, safe_get, upload_files_to_storage +from utilities.slack import actions +from utilities.slack.orm import ( + ActionsBlock, + BlockView, + ButtonElement, + CheckboxInputElement, + ContextBlock, + ContextElement, + DatepickerElement, + DividerBlock, + ExternalSelectElement, + FileInputElement, + HeaderBlock, + ImageBlock, + InputBlock, + PlainTextInputElement, + as_selector_options, +) + +USER_FORM_USERNAME = "user_name" +USER_FORM_HOME_REGION = "user_home_region" +USER_FORM_IMAGE = "user_image" +USER_FORM_IMAGE_UPLOAD = "user_image_upload" +USER_FORM_ID = "user_form_id" +USER_FORM_EMERGENCY_CONTACT = "user_emergency_contact" +USER_FORM_EMERGENCY_CONTACT_PHONE = "user_emergency_contact_phone" +USER_FORM_EMERGENCY_CONTACT_NOTES = "user_emergency_contact_notes" +IGNORE_EVENT = "user_ignore_event" +USER_FORM_START_DATE = "user_start_date" +USER_META_START_DATE = "start_date_override" +USER_EMERGENCY_INFO_SHARING = "user_emergency_info_dr_sharing" +USER_FORM_BROUGHT_BY = "user_brought_by" +USER_META_BROUGHT_BY = "brought_by" +USER_FORM_F3_NAME_ORIGIN = "user_f3_name_origin" +USER_META_F3_NAME_ORIGIN = "f3_name_origin" +USER_FORM_F3_WHY = "user_f3_why" +USER_META_F3_WHY = "my_f3_why" + + +def build_user_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + form = copy.deepcopy(FORM) + + slack_user: SlackUser = get_user( + safe_get(body, "user", "id") or safe_get(body, "user_id"), region_record, client, logger + ) + user = DbManager.get(User, slack_user.user_id, joinedloads=[User.home_region_org]) + + initial_values = { + USER_FORM_USERNAME: user.f3_name, + USER_FORM_EMERGENCY_CONTACT: user.emergency_contact, + USER_FORM_EMERGENCY_CONTACT_PHONE: user.emergency_phone, + USER_FORM_EMERGENCY_CONTACT_NOTES: user.emergency_notes, + USER_FORM_START_DATE: user.meta.get(USER_META_START_DATE) if user.meta else None, + USER_EMERGENCY_INFO_SHARING: "enabled" if safe_get(user.meta, USER_EMERGENCY_INFO_SHARING) else None, + USER_FORM_F3_NAME_ORIGIN: safe_get(user.meta, USER_META_F3_NAME_ORIGIN) if user.meta else None, + USER_FORM_F3_WHY: safe_get(user.meta, USER_META_F3_WHY) if user.meta else None, + } + brought_by_id = safe_convert(safe_get(user.meta, USER_META_BROUGHT_BY) if user.meta else None, int) + if brought_by_id: + brought_by_user = DbManager.get(User, brought_by_id, joinedloads=[User.home_region_org]) + if brought_by_user: + display_name = brought_by_user.f3_name + if brought_by_user.home_region_org: + display_name += f" ({brought_by_user.home_region_org.name})" + initial_values[USER_FORM_BROUGHT_BY] = { + "text": display_name, + "value": str(brought_by_user.id), + } + if user.home_region_id: + initial_values[USER_FORM_HOME_REGION] = { + "text": user.home_region_org.name, + "value": str(user.home_region_id), + } + if user.avatar_url: + initial_values[USER_FORM_IMAGE] = user.avatar_url + else: + form.delete_block(USER_FORM_IMAGE) + form.set_initial_values(initial_values) + + if os.getenv("STATS_URL") is None: + form.blocks.pop(3) + else: + action_block_index = next(i for i, block in enumerate(form.blocks) if isinstance(block, ActionsBlock)) + stats_url = f"{os.getenv('STATS_URL')}/stats/pax/{user.id}" + form.blocks[action_block_index].elements[0].url = stats_url + + try: + if safe_get(body, actions.LOADING_ID): + form.update_modal( + client=client, + view_id=safe_get(body, actions.LOADING_ID), + title_text="User Settings", + callback_id=USER_FORM_ID, + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="User Settings", + callback_id=USER_FORM_ID, + new_or_add="add", + ) + except Exception as e: + # try loading again without the image block in case the issue is with loading the user's avatar + logger.error(f"Error posting user form: {e}, retrying without image block") + form.delete_block(USER_FORM_IMAGE) + if safe_get(body, actions.LOADING_ID): + form.update_modal( + client=client, + view_id=safe_get(body, actions.LOADING_ID), + title_text="User Settings", + callback_id=USER_FORM_ID, + ) + else: + form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + title_text="User Settings", + callback_id=USER_FORM_ID, + new_or_add="add", + ) + + +def handle_user_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + form_data = FORM.get_selected_values(body) + slack_user: SlackUser = get_user( + safe_get(body, "user", "id") or safe_get(body, "user_id"), region_record, client, logger + ) + if slack_user.user_id: + user = DbManager.get(User, slack_user.user_id) + if user: + metadata = user.meta or {} + metadata[USER_EMERGENCY_INFO_SHARING] = "enabled" in form_data.get(USER_EMERGENCY_INFO_SHARING, []) + metadata[USER_META_START_DATE] = safe_get(form_data, USER_FORM_START_DATE) + metadata[USER_META_BROUGHT_BY] = safe_convert(safe_get(form_data, USER_FORM_BROUGHT_BY), int) + metadata[USER_META_F3_NAME_ORIGIN] = safe_get(form_data, USER_FORM_F3_NAME_ORIGIN) + metadata[USER_META_F3_WHY] = safe_get(form_data, USER_FORM_F3_WHY) + update_fields = { + User.f3_name: safe_get(form_data, USER_FORM_USERNAME), + User.home_region_id: safe_get(form_data, USER_FORM_HOME_REGION), + User.emergency_contact: safe_get(form_data, USER_FORM_EMERGENCY_CONTACT), + User.emergency_phone: safe_get(form_data, USER_FORM_EMERGENCY_CONTACT_PHONE), + User.emergency_notes: safe_get(form_data, USER_FORM_EMERGENCY_CONTACT_NOTES), + User.meta: metadata, + } + + file = safe_get(form_data, USER_FORM_IMAGE_UPLOAD, 0) + if file: + file_list, file_send_list, file_ids, low_rez_file_ids = upload_files_to_storage( + [file], + client=client, + logger=logger, + bucket_name="user-avatars", + file_name=str(slack_user.user_id), + enforce_png=True, + ) + update_fields[User.avatar_url] = file_list[0] + + DbManager.update_record(User, slack_user.user_id, update_fields) + + +FORM = BlockView( + blocks=[ + InputBlock( + label="Username", + action=USER_FORM_USERNAME, + element=PlainTextInputElement(placeholder="Enter your username"), + optional=False, + hint="This is the username that will be used to identify globally. Do not include your home region", + ), + InputBlock( + label="Home Region", + action=USER_FORM_HOME_REGION, + element=ExternalSelectElement(placeholder="Select a new home region"), + optional=False, + hint="This is the region you will be associated with. You can change this at any time.", + ), + ImageBlock(action=USER_FORM_IMAGE, image_url="https://example.com/image.png", alt_text="User Image"), + ContextBlock( + element=ContextElement( + initial_value="This avatar is used in the Nation dashboard and can be different from your Slack avatar." + ) + ), + InputBlock( + label="New Profile Picture", + action=USER_FORM_IMAGE_UPLOAD, + element=FileInputElement( + max_files=1, + filetypes=[ + "png", + "jpg", + "heic", + "bmp", + ], + ), + optional=True, + ), + DividerBlock(), + InputBlock( + label="Start Date Override", + action=USER_FORM_START_DATE, + element=DatepickerElement(placeholder="Select your start date"), + optional=True, + hint="This only needs to be filled if you need to override your official start date for any reason.", + ), + InputBlock( + label="Who brought you to F3?", + action=USER_FORM_BROUGHT_BY, + element=ExternalSelectElement(placeholder="Type to search..."), + optional=True, + hint="Select the PAX who introduced you to F3. To filter by home region, include the region in parentheses after their name, e.g. 'money (wash)' -> Moneyball (WashMo).", # noqa: E501 + ), + ActionsBlock( + elements=[ + ButtonElement( + label=":bar_chart: My Stats :link:", + url=os.getenv("STATS_URL"), + action=IGNORE_EVENT, + ), + ] + ), + DividerBlock(), + HeaderBlock(label="Emergency Contact Information"), + InputBlock( + label="Enable Downrange Access?", + action=USER_EMERGENCY_INFO_SHARING, + element=CheckboxInputElement( + options=as_selector_options( + names=["Enable"], + values=["enabled"], + ) + ), + optional=True, + hint="If enabled, users can search for your info from other Slack workspaces.", + ), + InputBlock( + label="Emergency Contact", + action=USER_FORM_EMERGENCY_CONTACT, + element=PlainTextInputElement(placeholder="Enter an emergency contact name"), + optional=True, + ), + InputBlock( + label="Emergency Contact Phone", + action=USER_FORM_EMERGENCY_CONTACT_PHONE, + element=PlainTextInputElement(placeholder="Enter an emergency contact phone number"), + optional=True, + ), + InputBlock( + label="Emergency Contact Notes", + action=USER_FORM_EMERGENCY_CONTACT_NOTES, + element=PlainTextInputElement( + placeholder="Enter any notes in case of an emergency (e.g., allergies, medical conditions)", + multiline=True, + ), + optional=True, + ), + DividerBlock(), + InputBlock( + label="What is the origin of your F3 name?", + action=USER_FORM_F3_NAME_ORIGIN, + element=PlainTextInputElement( + placeholder="Tell us the story behind your F3 name...", + multiline=True, + ), + optional=True, + ), + InputBlock( + label="What is your why?", + action=USER_FORM_F3_WHY, + element=PlainTextInputElement( + placeholder="Share what drives you to post in the gloom...", + multiline=True, + ), + optional=True, + ), + ] +) diff --git a/apps/slackbot/features/weaselbot.py b/apps/slackbot/features/weaselbot.py new file mode 100644 index 00000000..0fc46583 --- /dev/null +++ b/apps/slackbot/features/weaselbot.py @@ -0,0 +1,132 @@ +""" +Weaselbot configuration module. + +NOTE: Achievement tagging functionality has been moved to features/achievements.py. +This module now only handles Kotter Reports configuration and legacy achievement settings. +For new achievement functionality, use the achievements module. +""" + +import copy +from logging import Logger + +from f3_data_models.models import ( + Achievement, + SlackSpace, +) +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient +from sqlalchemy import or_ +from sqlalchemy.exc import ProgrammingError + +from utilities.database.orm import SlackSettings +from utilities.helper_functions import ( + safe_convert, + safe_get, + update_local_region_records, +) +from utilities.slack import actions, forms + + +# ============================================================================= +# DEPRECATED: Use features.achievements.build_tag_achievement_form instead +# ============================================================================= +def build_achievement_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + """ + DEPRECATED: This function is kept for backwards compatibility. + Use features.achievements.build_tag_achievement_form instead. + """ + from features.achievements import build_tag_achievement_form + + return build_tag_achievement_form(body, client, logger, context, region_record) + + +# ============================================================================= +# DEPRECATED: Use features.achievements.handle_tag_achievement instead +# ============================================================================= +def handle_achievements_tag(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + """ + DEPRECATED: This function is kept for backwards compatibility. + Use features.achievements.handle_tag_achievement instead. + """ + from features.achievements import handle_tag_achievement + + return handle_tag_achievement(body, client, logger, context, region_record) + + +def build_config_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + # paxminer_schema = region_record.paxminer_schema + # update_view_id = safe_get(body, actions.LOADING_ID) + config_form = copy.deepcopy(forms.WEASELBOT_CONFIG_FORM) + callback_id = actions.WEASELBOT_CONFIG_CALLBACK_ID + trigger_id = safe_get(body, "trigger_id") + + try: + weaselbot_achievements = DbManager.find_records( + Achievement, + [or_(Achievement.specific_org_id == region_record.org_id, Achievement.specific_org_id.is_(None))], + ) + except ProgrammingError: + weaselbot_achievements = None + + if not weaselbot_achievements: + config_form = copy.deepcopy(forms.NO_WEASELBOT_CONFIG_FORM) + config_form.post_modal( + client=client, + trigger_id=trigger_id, + callback_id=callback_id, + new_or_add="add", + title_text="Achievement Settings", + submit_button_text="None", + ) + else: + initial_features = [] + if region_record.send_achievements: + initial_features.append("achievements") + if region_record.send_aoq_reports: + initial_features.append("kotter_reports") + + config_form.set_initial_values( + { + actions.WEASELBOT_ENABLE_FEATURES: initial_features, + actions.WEASELBOT_ACHIEVEMENT_CHANNEL: region_record.achievement_channel, + actions.WEASELBOT_KOTTER_CHANNEL: region_record.default_siteq, + actions.WEASELBOT_KOTTER_WEEKS: region_record.NO_POST_THRESHOLD, + actions.WEASELBOT_KOTTER_REMOVE_WEEKS: region_record.REMINDER_WEEKS, + actions.WEASELBOT_HOME_AO_WEEKS: region_record.HOME_AO_CAPTURE, + actions.WEASELBOT_Q_WEEKS: region_record.NO_Q_THRESHOLD_WEEKS, + actions.WEASELBOT_Q_POSTS: region_record.NO_Q_THRESHOLD_POSTS, + } + ) + + config_form.post_modal( + client=client, + trigger_id=trigger_id, + callback_id=callback_id, + new_or_add="add", + title_text="Achievement Settings", + ) + + +def handle_config_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + config_data = forms.WEASELBOT_CONFIG_FORM.get_selected_values(body) + + region_record.send_achievements = ( + 1 if "achievements" in safe_get(config_data, actions.WEASELBOT_ENABLE_FEATURES) else 0 + ) + region_record.send_aoq_reports = ( + 1 if "kotter_reports" in safe_get(config_data, actions.WEASELBOT_ENABLE_FEATURES) else 0 + ) + region_record.achievement_channel = safe_get(config_data, actions.WEASELBOT_ACHIEVEMENT_CHANNEL) + region_record.default_siteq = safe_get(config_data, actions.WEASELBOT_KOTTER_CHANNEL) + region_record.NO_POST_THRESHOLD = safe_convert(safe_get(config_data, actions.WEASELBOT_KOTTER_WEEKS), int) + region_record.REMINDER_WEEKS = safe_convert(safe_get(config_data, actions.WEASELBOT_KOTTER_REMOVE_WEEKS), int) + region_record.HOME_AO_CAPTURE = safe_convert(safe_get(config_data, actions.WEASELBOT_HOME_AO_WEEKS), int) + region_record.NO_Q_THRESHOLD_WEEKS = safe_convert(safe_get(config_data, actions.WEASELBOT_Q_WEEKS), int) + region_record.NO_Q_THRESHOLD_POSTS = safe_convert(safe_get(config_data, actions.WEASELBOT_Q_POSTS), int) + + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + update_local_region_records() diff --git a/apps/slackbot/features/welcome.py b/apps/slackbot/features/welcome.py new file mode 100644 index 00000000..6f98e30c --- /dev/null +++ b/apps/slackbot/features/welcome.py @@ -0,0 +1,103 @@ +import copy +import random +from logging import Logger + +from f3_data_models.models import Org, SlackSpace +from f3_data_models.utils import DbManager +from slack_sdk.web import WebClient + +from utilities import constants +from utilities.database.orm import SlackSettings +from utilities.helper_functions import ( + safe_get, + update_local_region_records, +) +from utilities.slack import actions, forms + + +def build_welcome_config_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + welcome_message_config_form = copy.deepcopy(forms.WELCOME_MESSAGE_CONFIG_FORM) + + welcome_message_config_form.set_initial_values( + { + actions.WELCOME_DM_TEMPLATE: region_record.welcome_dm_template, + actions.WELCOME_DM_ENABLE: "enable" if region_record.welcome_dm_enable else "disable", + actions.WELCOME_CHANNEL: region_record.welcome_channel or "", + actions.WELCOME_CHANNEL_ENABLE: "enable" if region_record.welcome_channel_enable else "disable", + } + ) + + welcome_message_config_form.post_modal( + client=client, + trigger_id=safe_get(body, "trigger_id"), + callback_id=actions.WELCOME_MESSAGE_CONFIG_CALLBACK_ID, + title_text="Welcomebot Settings", + new_or_add="add", + ) + + +# eventually will not need this when we take out the /config-welcome-message command +def build_welcome_message_form( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + update_view_id = safe_get(body, actions.LOADING_ID) + welcome_message_config_form = copy.deepcopy(forms.WELCOME_MESSAGE_CONFIG_FORM) + + welcome_message_config_form.set_initial_values( + { + actions.WELCOME_DM_TEMPLATE: region_record.welcome_dm_template, + actions.WELCOME_DM_ENABLE: "enable" if region_record.welcome_dm_enable else "disable", + actions.WELCOME_CHANNEL: region_record.welcome_channel or "", + actions.WELCOME_CHANNEL_ENABLE: "enable" if region_record.welcome_channel_enable else "disable", + } + ) + + welcome_message_config_form.update_modal( + client=client, + view_id=update_view_id, + callback_id=actions.WELCOME_MESSAGE_CONFIG_CALLBACK_ID, + title_text="Welcomebot Settings", + parent_metadata=None, + ) + + +def handle_welcome_message_config_post( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + welcome_config_data = forms.WELCOME_MESSAGE_CONFIG_FORM.get_selected_values(body) + + region_record.welcome_dm_enable = 1 if safe_get(welcome_config_data, actions.WELCOME_DM_ENABLE) == "enable" else 0 + region_record.welcome_dm_template = safe_get(welcome_config_data, actions.WELCOME_DM_TEMPLATE) or "" + region_record.welcome_channel_enable = ( + 1 if safe_get(welcome_config_data, actions.WELCOME_CHANNEL_ENABLE) == "enable" else 0 + ) + region_record.welcome_channel = safe_get(welcome_config_data, actions.WELCOME_CHANNEL) or "" + + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + + update_local_region_records() + + +def handle_team_join(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + welcome_channel = region_record.welcome_channel + workspace_name = region_record.workspace_name + user_id = safe_get(body, "event", "user", "id") + + if region_record.welcome_dm_enable: + client.chat_postMessage(channel=user_id, blocks=[region_record.welcome_dm_template], text="Welcome!") + if region_record.welcome_channel_enable: + if region_record.org_id: + org_record = DbManager.get(Org, region_record.org_id) + org_name = org_record.name if org_record else workspace_name + else: + org_name = workspace_name + client.chat_postMessage( + channel=welcome_channel, + text=random.choice(constants.WELCOME_MESSAGE_TEMPLATES).format(user=f"<@{user_id}>", region=org_name), + ) diff --git a/apps/slackbot/infrastructure/__init__.py b/apps/slackbot/infrastructure/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/slackbot/infrastructure/api_client/__init__.py b/apps/slackbot/infrastructure/api_client/__init__.py new file mode 100644 index 00000000..3de85c95 --- /dev/null +++ b/apps/slackbot/infrastructure/api_client/__init__.py @@ -0,0 +1,34 @@ +from infrastructure.api_client.ao_repository import ApiAoRepository, get_api_ao_repository +from infrastructure.api_client.client import F3ApiClient, get_f3_api_client +from infrastructure.api_client.event_instance_repository import ( + ApiEventInstanceRepository, + get_api_event_instance_repository, +) +from infrastructure.api_client.event_tag_repository import ApiEventTagRepository, get_api_event_tag_repository +from infrastructure.api_client.event_type_repository import ApiEventTypeRepository, get_api_event_type_repository +from infrastructure.api_client.exceptions import F3ApiAuthError, F3ApiError, F3ApiNotFoundError +from infrastructure.api_client.location_repository import ApiLocationRepository, get_api_location_repository +from infrastructure.api_client.position_repository import ApiPositionRepository, get_api_position_repository +from infrastructure.api_client.series_repository import ApiSeriesRepository, get_api_series_repository + +__all__ = [ + "F3ApiClient", + "get_f3_api_client", + "ApiAoRepository", + "get_api_ao_repository", + "ApiEventInstanceRepository", + "get_api_event_instance_repository", + "ApiEventTagRepository", + "get_api_event_tag_repository", + "ApiEventTypeRepository", + "get_api_event_type_repository", + "ApiLocationRepository", + "get_api_location_repository", + "ApiPositionRepository", + "get_api_position_repository", + "ApiSeriesRepository", + "get_api_series_repository", + "F3ApiError", + "F3ApiNotFoundError", + "F3ApiAuthError", +] diff --git a/apps/slackbot/infrastructure/api_client/ao_repository.py b/apps/slackbot/infrastructure/api_client/ao_repository.py new file mode 100644 index 00000000..44a44510 --- /dev/null +++ b/apps/slackbot/infrastructure/api_client/ao_repository.py @@ -0,0 +1,131 @@ +""" +API-backed implementation of ``AoRepository``. + +Maps responses from the F3 Nation REST API to ``AoData`` objects. +Uses ``GET /v1/org`` filtered to ``orgTypes=ao`` and ``parentOrgIds`` +to list AOs for a given region, and ``POST /v1/org`` (crupdate) for +create/update operations. +""" + +from __future__ import annotations + +from application.ao import AoData +from infrastructure.api_client.client import F3ApiClient, get_f3_api_client +from infrastructure.api_client.exceptions import F3ApiNotFoundError + + +def _parse_ao(raw: dict) -> AoData: + return AoData( + id=raw["id"], + name=raw["name"], + parent_id=raw.get("parentId", raw.get("parent_id")), + org_type=raw.get("orgType", raw.get("org_type", "ao")), + description=raw.get("description"), + is_active=raw.get("isActive", raw.get("is_active", True)), + default_location_id=raw.get("defaultLocationId", raw.get("default_location_id")), + logo_url=raw.get("logoUrl", raw.get("logo_url")), + meta=raw.get("meta"), + ) + + +class ApiAoRepository: + """Fetches and mutates AOs via the F3 Nation REST API.""" + + def __init__(self, client: F3ApiClient) -> None: + self._client = client + + def get_by_parent_org(self, parent_org_id: int) -> list[AoData]: + """Return active AOs whose parent org is *parent_org_id*.""" + result = self._client.get( + "/v1/org", + params={"orgTypes": ["ao"], "parentOrgIds": [parent_org_id], "statuses": ["active"]}, + ) + orgs_raw: list[dict] = result.get("orgs") or result.get("results") or [] + return [_parse_ao(o) for o in orgs_raw] + + def get_by_id(self, ao_id: int) -> AoData | None: + """Return a single AO by primary key, or None if not found.""" + try: + result = self._client.get(f"/v1/org/id/{ao_id}") + except F3ApiNotFoundError: + return None + raw = result.get("org") or result.get("result") + return _parse_ao(raw) if raw else None + + def create( + self, + parent_id: int, + name: str, + description: str | None, + slack_channel_id: str | None, + default_location_id: int | None, + ) -> AoData: + """Create a new AO and return the created record.""" + payload: dict = { + "name": name, + "orgType": "ao", + "parentId": parent_id, + "isActive": True, + "website": "", + "twitter": "", + "facebook": "", + "instagram": "", + "meta": {"slack_channel_id": slack_channel_id} if slack_channel_id else {}, + } + if description is not None: + payload["description"] = description + if default_location_id is not None: + payload["defaultLocationId"] = default_location_id + result = self._client.post("/v1/org", json=payload) + raw = result.get("org") or result.get("result") or result + return _parse_ao(raw) + + def update( + self, + ao_id: int, + parent_id: int, + name: str, + description: str | None, + slack_channel_id: str | None, + default_location_id: int | None, + logo_url: str | None = None, + ) -> None: + """Update an existing AO (crupdate POST — all required fields must be sent).""" + payload: dict = { + "id": ao_id, + "name": name, + "orgType": "ao", + "parentId": parent_id, + "isActive": True, + "website": "", + "twitter": "", + "facebook": "", + "instagram": "", + "meta": {"slack_channel_id": slack_channel_id} if slack_channel_id else {}, + } + if description is not None: + payload["description"] = description + if default_location_id is not None: + payload["defaultLocationId"] = default_location_id + if logo_url is not None: + payload["logoUrl"] = logo_url + self._client.post("/v1/org", json=payload) + + def delete(self, ao_id: int) -> None: + """Soft-delete an AO (API cascades to associated events/instances).""" + self._client.delete(f"/v1/org/delete/{ao_id}") + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_repo: ApiAoRepository | None = None + + +def get_api_ao_repository() -> ApiAoRepository: + """Return the shared ``ApiAoRepository`` instance.""" + global _repo + if _repo is None: + _repo = ApiAoRepository(get_f3_api_client()) + return _repo diff --git a/apps/slackbot/infrastructure/api_client/client.py b/apps/slackbot/infrastructure/api_client/client.py new file mode 100644 index 00000000..4ca918ec --- /dev/null +++ b/apps/slackbot/infrastructure/api_client/client.py @@ -0,0 +1,118 @@ +""" +F3 Nation REST API HTTP client. + +Injects the required ``Authorization`` and ``Client`` headers on every +request. A single module-level instance is shared across all callers +(created lazily on first use) so that the underlying ``requests.Session`` +connection pool is reused. + +Required environment variable: + F3_API_KEY – Bearer token / API key for the F3 Nation API. + +Optional: + F3_API_BASE_URL – Override the base URL (defaults to + https://api.f3nation.com). Useful for local + testing against a dev API instance. +""" + +from __future__ import annotations + +import os +from typing import Any + +import requests + +from infrastructure.api_client.exceptions import F3ApiAuthError, F3ApiError, F3ApiNotFoundError + +_DEFAULT_BASE_URL = "https://api.f3nation.com" +_CLIENT_IDENTIFIER = "f3-nation-slack-bot" +_DEFAULT_TIMEOUT_SECONDS = 8.0 + + +class F3ApiClient: + """Thin wrapper around ``requests.Session`` that handles auth headers and + maps HTTP error codes to typed exceptions.""" + + def __init__(self) -> None: + api_key = os.environ.get("F3_API_KEY") + if not api_key: + raise ValueError("F3_API_KEY is required for F3ApiClient") + + base_url = os.environ.get("F3_API_BASE_URL", _DEFAULT_BASE_URL).rstrip("/") + timeout_raw = os.environ.get("F3_API_TIMEOUT_SECONDS", str(_DEFAULT_TIMEOUT_SECONDS)) + + self._base_url = base_url + self._timeout_seconds = _DEFAULT_TIMEOUT_SECONDS + try: + self._timeout_seconds = float(timeout_raw) + except (TypeError, ValueError): + self._timeout_seconds = _DEFAULT_TIMEOUT_SECONDS + + self._session = requests.Session() + self._session.headers.update( + { + "Authorization": f"Bearer {api_key}", + "Client": _CLIENT_IDENTIFIER, + "Content-Type": "application/json", + } + ) + + # ------------------------------------------------------------------ + # Public verb methods + # ------------------------------------------------------------------ + + def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + return self._request("get", path, params=params) + + def post(self, path: str, json: dict[str, Any] | None = None) -> Any: + return self._request("post", path, json=json) + + def put(self, path: str, json: dict[str, Any] | None = None) -> Any: + return self._request("put", path, json=json) + + def delete(self, path: str, json: dict[str, Any] | None = None) -> Any: + return self._request("delete", path, json=json) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _request(self, method: str, path: str, **kwargs) -> Any: + request_fn = getattr(self._session, method) + url = f"{self._base_url}{path}" + try: + response = request_fn(url, timeout=self._timeout_seconds, **kwargs) + except requests.RequestException as exc: + raise F3ApiError(0, f"Network error calling F3 API {method.upper()} {path}: {exc}") from exc + return self._handle_response(response) + + def _handle_response(self, response: requests.Response) -> Any: + if response.status_code == 404: + raise F3ApiNotFoundError(404, response.text) + if response.status_code in (401, 403): + raise F3ApiAuthError(response.status_code, response.text) + if not response.ok: + raise F3ApiError(response.status_code, response.text) + + if response.status_code == 204: + return None + + try: + return response.json() + except ValueError: + return response.text + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_client: F3ApiClient | None = None + + +def get_f3_api_client() -> F3ApiClient: + """Return the shared ``F3ApiClient`` instance, creating it on first call.""" + global _client + if _client is None: + _client = F3ApiClient() + return _client diff --git a/apps/slackbot/infrastructure/api_client/event_instance_repository.py b/apps/slackbot/infrastructure/api_client/event_instance_repository.py new file mode 100644 index 00000000..c4a9e480 --- /dev/null +++ b/apps/slackbot/infrastructure/api_client/event_instance_repository.py @@ -0,0 +1,307 @@ +""" +API-backed implementation of ``EventInstanceRepository``. + +Maps responses from the F3 Nation REST API to ``EventInstanceData`` objects. + +Endpoints used: + GET /v1/event-instance - list (filter by regionOrgId, aoOrgId, startDate) + GET /v1/event-instance/id/{id} - single + POST /v1/event-instance - create or update (crupdate) + DELETE /v1/event-instance/id/{id} - hard delete + +Note: DELETE on event-instance is a HARD delete (unlike most other domains which soft-delete). +Close and reopen are implemented via crupdate POST using the existing instance details plus the +updated seriesException value. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +from application.event_instance import EventInstanceData +from infrastructure.api_client.client import F3ApiClient, get_f3_api_client +from infrastructure.api_client.exceptions import F3ApiNotFoundError + + +def _parse_instance(raw: dict) -> EventInstanceData: + """Convert a raw API response dict to an ``EventInstanceData`` object.""" + # event_type_ids: API may return nested objects array (eventTypes), + # a plain ID array (event_types), or a single number (eventTypeId). + event_types_raw = raw.get("eventTypes", raw.get("event_types", [])) + if event_types_raw and isinstance(event_types_raw[0], dict): + event_type_ids = [t["eventTypeId"] for t in event_types_raw] + elif event_types_raw: + event_type_ids = [int(t) for t in event_types_raw if t is not None] + else: + et = raw.get("eventTypeId", raw.get("event_type_id")) + event_type_ids = [int(et)] if et is not None else [] + + event_tags_raw = raw.get("eventTags", raw.get("event_tags", [])) + if event_tags_raw and isinstance(event_tags_raw[0], dict): + event_tag_ids = [t["eventTagId"] for t in event_tags_raw] + elif event_tags_raw: + event_tag_ids = [int(t) for t in event_tags_raw if t is not None] + else: + etag = raw.get("eventTagId", raw.get("event_tag_id")) + event_tag_ids = [int(etag)] if etag is not None else [] + + # startDate may come as "YYYY-MM-DD" string or a date object + raw_start_date = raw.get("startDate", raw.get("start_date")) + if isinstance(raw_start_date, str): + from datetime import datetime + + start_date = datetime.strptime(raw_start_date, "%Y-%m-%d").date() + elif isinstance(raw_start_date, date): + start_date = raw_start_date + else: + start_date = None + + return EventInstanceData( + id=raw["id"], + name=raw.get("name"), + description=raw.get("description"), + org_id=raw.get("orgId", raw.get("org_id", 0)), + location_id=raw.get("locationId", raw.get("location_id")), + event_type_ids=event_type_ids, + event_tag_ids=event_tag_ids, + start_date=start_date, + start_time=raw.get("startTime", raw.get("start_time")), + end_time=raw.get("endTime", raw.get("end_time")), + is_active=raw.get("isActive", raw.get("is_active", True)), + is_private=raw.get("isPrivate", raw.get("is_private", False)), + meta=raw.get("meta"), + highlight=raw.get("highlight", False), + preblast_rich=raw.get("preblastRich", raw.get("preblast_rich")), + preblast=raw.get("preblast"), + series_exception=raw.get("seriesException", raw.get("series_exception")), + ) + + +def _build_crupdate_payload( + name: str, + org_id: int, + start_date: date, + start_time: str, + end_time: str, + description: str | None, + location_id: int | None, + event_type_id: int, + event_tag_id: int | None, + is_active: bool, + is_private: bool, + meta: dict | None, + highlight: bool, + preblast_rich: Any | None, + preblast: str | None, +) -> dict: + # The API accepts a single eventTypeId and eventTagId (not arrays). + payload: dict = { + "name": name, + "orgId": org_id, + "startDate": start_date.strftime("%Y-%m-%d"), + "startTime": start_time, + "endTime": end_time, + "isActive": is_active, + "isPrivate": is_private, + "highlight": highlight, + "eventTypeId": event_type_id, + } + if event_tag_id is not None: + payload["eventTagId"] = event_tag_id + if description is not None: + payload["description"] = description + if location_id is not None: + payload["locationId"] = location_id + if meta is not None: + payload["meta"] = meta + if preblast_rich is not None: + payload["preblastRich"] = preblast_rich + if preblast is not None: + payload["preblast"] = preblast + return payload + + +def _build_state_change_payload( + instance: EventInstanceData, + *, + series_exception: str | None, + meta: dict | None = None, +) -> dict: + if not instance.name: + raise ValueError(f"Event instance {instance.id} is missing required field 'name'") + if not instance.org_id: + raise ValueError(f"Event instance {instance.id} is missing required field 'org_id'") + if instance.start_date is None: + raise ValueError(f"Event instance {instance.id} is missing required field 'start_date'") + if not instance.start_time: + raise ValueError(f"Event instance {instance.id} is missing required field 'start_time'") + if not instance.end_time: + raise ValueError(f"Event instance {instance.id} is missing required field 'end_time'") + if not instance.event_type_ids: + raise ValueError(f"Event instance {instance.id} is missing required field 'event_type_ids'") + + payload = _build_crupdate_payload( + name=instance.name, + org_id=instance.org_id, + start_date=instance.start_date, + start_time=instance.start_time, + end_time=instance.end_time, + description=instance.description, + location_id=instance.location_id, + event_type_id=instance.event_type_ids[0], + event_tag_id=instance.event_tag_ids[0] if instance.event_tag_ids else None, + is_active=instance.is_active, + is_private=instance.is_private, + meta=instance.meta if meta is None else meta, + highlight=instance.highlight, + preblast_rich=instance.preblast_rich, + preblast=instance.preblast, + ) + payload["id"] = instance.id + payload["seriesException"] = series_exception + return payload + + +class ApiEventInstanceRepository: + """Fetches and mutates event instances via the F3 Nation REST API.""" + + def __init__(self, client: F3ApiClient) -> None: + self._client = client + + def get_list( + self, + region_org_id: int, + start_date: date, + ao_org_id: int | None = None, + ) -> list[EventInstanceData]: + params: dict = { + "regionOrgId": region_org_id, + "startDate": start_date.strftime("%Y-%m-%d"), + } + if ao_org_id is not None: + params["aoOrgId"] = ao_org_id + result = self._client.get("/v1/event-instance", params=params) + raw_list: list[dict] = result.get("eventInstances") or result.get("results") or [] + return [_parse_instance(i) for i in raw_list] + + def get_by_id(self, instance_id: int) -> EventInstanceData | None: + try: + result = self._client.get(f"/v1/event-instance/id/{instance_id}") + except F3ApiNotFoundError: + return None + raw = result.get("eventInstance") or result.get("result") or result + return _parse_instance(raw) if raw else None + + def create( + self, + name: str, + org_id: int, + start_date: date, + start_time: str, + end_time: str, + description: str | None, + location_id: int | None, + event_type_ids: list[int], + event_tag_ids: list[int], + is_active: bool, + is_private: bool, + meta: dict | None, + highlight: bool, + preblast_rich: Any | None, + preblast: str | None, + ) -> EventInstanceData: + payload = _build_crupdate_payload( + name=name, + org_id=org_id, + start_date=start_date, + start_time=start_time, + end_time=end_time, + description=description, + location_id=location_id, + event_type_id=event_type_ids[0] if event_type_ids else 0, + event_tag_id=event_tag_ids[0] if event_tag_ids else None, + is_active=is_active, + is_private=is_private, + meta=meta, + highlight=highlight, + preblast_rich=preblast_rich, + preblast=preblast, + ) + result = self._client.post("/v1/event-instance", json=payload) + raw = result.get("eventInstance") or result.get("result") or result + return _parse_instance(raw) + + def update( + self, + instance_id: int, + name: str, + org_id: int, + start_date: date, + start_time: str, + end_time: str, + description: str | None, + location_id: int | None, + event_type_ids: list[int], + event_tag_ids: list[int], + is_active: bool, + is_private: bool, + meta: dict | None, + highlight: bool, + preblast_rich: Any | None, + preblast: str | None, + ) -> EventInstanceData: + payload = _build_crupdate_payload( + name=name, + org_id=org_id, + start_date=start_date, + start_time=start_time, + end_time=end_time, + description=description, + location_id=location_id, + event_type_id=event_type_ids[0] if event_type_ids else 0, + event_tag_id=event_tag_ids[0] if event_tag_ids else None, + is_active=is_active, + is_private=is_private, + meta=meta, + highlight=highlight, + preblast_rich=preblast_rich, + preblast=preblast, + ) + payload["id"] = instance_id + result = self._client.post("/v1/event-instance", json=payload) + raw = result.get("eventInstance") or result.get("result") or result + return _parse_instance(raw) + + def close(self, instance: EventInstanceData, meta: dict) -> None: + """Mark an instance as closed via a full crupdate POST.""" + self._client.post( + "/v1/event-instance", + json=_build_state_change_payload(instance, series_exception="closed", meta=meta), + ) + + def reopen(self, instance: EventInstanceData) -> None: + """Clear the seriesException via a full crupdate POST.""" + self._client.post( + "/v1/event-instance", + json=_build_state_change_payload(instance, series_exception=None), + ) + + def delete(self, instance_id: int) -> None: + """Hard-delete an event instance.""" + self._client.delete(f"/v1/event-instance/id/{instance_id}") + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_repo: ApiEventInstanceRepository | None = None + + +def get_api_event_instance_repository() -> ApiEventInstanceRepository: + """Return the shared ``ApiEventInstanceRepository`` instance.""" + global _repo + if _repo is None: + _repo = ApiEventInstanceRepository(get_f3_api_client()) + return _repo diff --git a/apps/slackbot/infrastructure/api_client/event_tag_repository.py b/apps/slackbot/infrastructure/api_client/event_tag_repository.py new file mode 100644 index 00000000..e48793ee --- /dev/null +++ b/apps/slackbot/infrastructure/api_client/event_tag_repository.py @@ -0,0 +1,86 @@ +""" +API-backed implementation of ``EventTagRepository``. + +Maps responses from the F3 Nation REST API to ``EventTagData`` objects. +The ``GET /v1/event-tag/org/{orgId}`` endpoint returns both nation-wide and +org-specific tags; this repository mirrors the legacy DbManager behaviour by +filtering to org-specific tags only (``specificOrgId`` is not null). +""" + +from __future__ import annotations + +from application.event_tag import EventTagData +from infrastructure.api_client.client import F3ApiClient, get_f3_api_client +from infrastructure.api_client.exceptions import F3ApiNotFoundError + + +def _parse_event_tag(raw: dict) -> EventTagData: + return EventTagData( + id=raw["id"], + name=raw["name"], + color=raw.get("color"), + specific_org_id=raw.get("specificOrgId", raw.get("specific_org_id")), + is_active=raw.get("isActive", raw.get("is_active", True)), + description=raw.get("description"), + ) + + +class ApiEventTagRepository: + """Fetches and mutates event tags via the F3 Nation REST API.""" + + def __init__(self, client: F3ApiClient) -> None: + self._client = client + + def _fetch_raw(self, org_id: int) -> list[dict]: + result = self._client.get("/v1/event-tag", params={"orgIds": [org_id], "statuses": ["active"]}) + raw = result.get("eventTags") or result.get("results") or [] + return [t for t in raw if t.get("isActive", t.get("is_active", True))] + + def get_by_org(self, org_id: int) -> list[EventTagData]: + """Return org-specific event tags for *org_id*.""" + tags_raw = self._fetch_raw(org_id) + return [_parse_event_tag(t) for t in tags_raw if t.get("specificOrgId", t.get("specific_org_id")) == org_id] + + def get_all_for_org(self, org_id: int) -> list[EventTagData]: + """Return org-specific and global event tags visible to *org_id*.""" + tags_raw = self._fetch_raw(org_id) + specific_org_id = lambda t: t.get("specificOrgId", t.get("specific_org_id")) # noqa: E731 + return [_parse_event_tag(t) for t in tags_raw if specific_org_id(t) == org_id or specific_org_id(t) is None] + + def get_by_id(self, tag_id: int) -> EventTagData | None: + try: + result = self._client.get(f"/v1/event-tag/id/{tag_id}") + except F3ApiNotFoundError: + return None + raw = result.get("eventTag") or result.get("result") + return _parse_event_tag(raw) if raw else None + + def create(self, name: str, color: str, org_id: int) -> None: + self._client.post( + "/v1/event-tag", + json={"name": name, "color": color, "specificOrgId": org_id, "isActive": True}, + ) + + def update(self, tag_id: int, name: str, color: str) -> None: + self._client.post( + "/v1/event-tag", + json={"id": tag_id, "name": name, "color": color}, + ) + + def delete(self, tag_id: int) -> None: + self._client.delete(f"/v1/event-tag/id/{tag_id}") + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_repo: ApiEventTagRepository | None = None + + +def get_api_event_tag_repository() -> ApiEventTagRepository: + """Return the shared ``ApiEventTagRepository`` instance.""" + global _repo + if _repo is None: + _repo = ApiEventTagRepository(get_f3_api_client()) + return _repo diff --git a/apps/slackbot/infrastructure/api_client/event_type_repository.py b/apps/slackbot/infrastructure/api_client/event_type_repository.py new file mode 100644 index 00000000..5778677e --- /dev/null +++ b/apps/slackbot/infrastructure/api_client/event_type_repository.py @@ -0,0 +1,95 @@ +""" +API-backed implementation of ``EventTypeRepository``. + +Maps responses from the F3 Nation REST API to ``EventTypeData`` objects. +The ``GET /v1/event-type/org/{orgId}`` endpoint returns both nation-wide +(global) and org-specific event types; filtering is applied client-side to +mirror the legacy DbManager behaviour. +""" + +from __future__ import annotations + +from application.event_type import EventTypeData +from infrastructure.api_client.client import F3ApiClient, get_f3_api_client +from infrastructure.api_client.exceptions import F3ApiNotFoundError + + +def _parse_event_type(raw: dict) -> EventTypeData: + return EventTypeData( + id=raw["id"], + name=raw["name"], + acronym=raw.get("acronym"), + event_category=raw.get("eventCategory", raw.get("event_category")), + specific_org_id=raw.get("specificOrgId", raw.get("specific_org_id")), + is_active=raw.get("isActive", raw.get("is_active", True)), + ) + + +def _specific_org_id(raw: dict) -> int | None: + return raw.get("specificOrgId", raw.get("specific_org_id")) + + +class ApiEventTypeRepository: + """Fetches and mutates event types via the F3 Nation REST API.""" + + def __init__(self, client: F3ApiClient) -> None: + self._client = client + + def _fetch_raw(self, org_id: int) -> list[dict]: + result = self._client.get("/v1/event-type", params={"orgIds": [org_id], "statuses": ["active"]}) + raw = result.get("eventTypes") or result.get("results") or [] + return [t for t in raw if t.get("isActive", t.get("is_active", True))] + + def get_by_org(self, org_id: int) -> list[EventTypeData]: + """Return only org-specific event types for *org_id*.""" + raw_list = self._fetch_raw(org_id) + return [_parse_event_type(t) for t in raw_list if _specific_org_id(t) == org_id] + + def get_all_for_org(self, org_id: int) -> list[EventTypeData]: + """Return org-specific and global event types visible to *org_id*.""" + raw_list = self._fetch_raw(org_id) + return [_parse_event_type(t) for t in raw_list if _specific_org_id(t) == org_id or _specific_org_id(t) is None] + + def get_by_id(self, event_type_id: int) -> EventTypeData | None: + try: + result = self._client.get(f"/v1/event-type/id/{event_type_id}") + except F3ApiNotFoundError: + return None + raw = result.get("eventType") or result.get("result") + return _parse_event_type(raw) if raw else None + + def create(self, name: str, acronym: str, event_category: str, org_id: int) -> None: + self._client.post( + "/v1/event-type", + json={ + "name": name, + "acronym": acronym, + "eventCategory": event_category, + "specificOrgId": org_id, + "isActive": True, + }, + ) + + def update(self, event_type_id: int, name: str, acronym: str, event_category: str) -> None: + self._client.post( + "/v1/event-type", + json={"id": event_type_id, "name": name, "acronym": acronym, "eventCategory": event_category}, + ) + + def delete(self, event_type_id: int) -> None: + self._client.delete(f"/v1/event-type/id/{event_type_id}") + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_repo: ApiEventTypeRepository | None = None + + +def get_api_event_type_repository() -> ApiEventTypeRepository: + """Return the shared ``ApiEventTypeRepository`` instance.""" + global _repo + if _repo is None: + _repo = ApiEventTypeRepository(get_f3_api_client()) + return _repo diff --git a/apps/slackbot/infrastructure/api_client/exceptions.py b/apps/slackbot/infrastructure/api_client/exceptions.py new file mode 100644 index 00000000..be6c0436 --- /dev/null +++ b/apps/slackbot/infrastructure/api_client/exceptions.py @@ -0,0 +1,14 @@ +class F3ApiError(Exception): + """Raised when the F3 Nation API returns a non-2xx status.""" + + def __init__(self, status_code: int, message: str) -> None: + self.status_code = status_code + super().__init__(f"F3 API error {status_code}: {message}") + + +class F3ApiNotFoundError(F3ApiError): + """Raised when the API returns 404.""" + + +class F3ApiAuthError(F3ApiError): + """Raised when the API returns 401 or 403.""" diff --git a/apps/slackbot/infrastructure/api_client/location_repository.py b/apps/slackbot/infrastructure/api_client/location_repository.py new file mode 100644 index 00000000..6ed8133d --- /dev/null +++ b/apps/slackbot/infrastructure/api_client/location_repository.py @@ -0,0 +1,147 @@ +""" +API-backed implementation of ``LocationRepository``. + +Maps responses from the F3 Nation REST API to ``LocationData`` objects. +""" + +from __future__ import annotations + +from application.location import LocationData +from infrastructure.api_client.client import F3ApiClient, get_f3_api_client +from infrastructure.api_client.exceptions import F3ApiNotFoundError + + +def _parse_location(raw: dict) -> LocationData: + return LocationData( + id=raw["id"], + name=raw.get("locationName", raw.get("name", "")), # API uses locationName + description=raw.get("description"), + latitude=raw.get("latitude"), + longitude=raw.get("longitude"), + address_street=raw.get("addressStreet", raw.get("address_street")), + address_street2=raw.get("addressStreet2", raw.get("address_street2")), + address_city=raw.get("addressCity", raw.get("address_city")), + address_state=raw.get("addressState", raw.get("address_state")), + address_zip=raw.get("addressZip", raw.get("address_zip")), + address_country=raw.get("addressCountry", raw.get("address_country")), + is_active=raw.get("isActive", raw.get("is_active", True)), + org_id=raw.get("orgId", raw.get("org_id")), + ) + + +class ApiLocationRepository: + """Fetches and mutates locations via the F3 Nation REST API.""" + + def __init__(self, client: F3ApiClient) -> None: + self._client = client + + def get_by_org(self, org_id: int) -> list[LocationData]: + """Return active locations for *org_id*.""" + result = self._client.get("/v1/location", params={"regionIds": [org_id]}) + locations_raw: list[dict] = result.get("locations") or result.get("results") or [] + return [_parse_location(loc) for loc in locations_raw] + + def get_by_id(self, location_id: int) -> LocationData | None: + try: + result = self._client.get(f"/v1/location/id/{location_id}") + except F3ApiNotFoundError: + return None + raw = result.get("location") or result.get("result") + return _parse_location(raw) if raw else None + + def create( + self, + name: str, + org_id: int, + description: str | None, + latitude: float | None, + longitude: float | None, + address_street: str | None, + address_street2: str | None, + address_city: str | None, + address_state: str | None, + address_zip: str | None, + address_country: str | None, + ) -> LocationData: + payload: dict = { + "name": name, + "orgId": org_id, + "isActive": True, + } + if description is not None: + payload["description"] = description + if latitude is not None: + payload["latitude"] = latitude + if longitude is not None: + payload["longitude"] = longitude + if address_street is not None: + payload["addressStreet"] = address_street + if address_street2 is not None: + payload["addressStreet2"] = address_street2 + if address_city is not None: + payload["addressCity"] = address_city + if address_state is not None: + payload["addressState"] = address_state + if address_zip is not None: + payload["addressZip"] = address_zip + if address_country is not None: + payload["addressCountry"] = address_country + + result = self._client.post("/v1/location", json=payload) + raw = result.get("location") or result.get("result") or result + return _parse_location(raw) + + def update( + self, + location_id: int, + name: str, + org_id: int, + is_active: bool = True, + description: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + address_street: str | None = None, + address_street2: str | None = None, + address_city: str | None = None, + address_state: str | None = None, + address_zip: str | None = None, + address_country: str | None = None, + ) -> None: + payload: dict = {"id": location_id, "name": name, "orgId": org_id, "isActive": is_active} + if description is not None: + payload["description"] = description + if latitude is not None: + payload["latitude"] = latitude + if longitude is not None: + payload["longitude"] = longitude + if address_street is not None: + payload["addressStreet"] = address_street + if address_street2 is not None: + payload["addressStreet2"] = address_street2 + if address_city is not None: + payload["addressCity"] = address_city + if address_state is not None: + payload["addressState"] = address_state + if address_zip is not None: + payload["addressZip"] = address_zip + if address_country is not None: + payload["addressCountry"] = address_country + self._client.post("/v1/location", json=payload) + + def delete(self, location_id: int) -> None: + self._client.delete(f"/v1/location/delete/{location_id}") + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_repo: ApiLocationRepository | None = None + + +def get_api_location_repository() -> ApiLocationRepository: + """Return the shared ``ApiLocationRepository`` instance.""" + global _repo + if _repo is None: + _repo = ApiLocationRepository(get_f3_api_client()) + return _repo diff --git a/apps/slackbot/infrastructure/api_client/position_repository.py b/apps/slackbot/infrastructure/api_client/position_repository.py new file mode 100644 index 00000000..66a22fb6 --- /dev/null +++ b/apps/slackbot/infrastructure/api_client/position_repository.py @@ -0,0 +1,140 @@ +""" +API-backed implementation of ``PositionRepository``. + +Maps responses from the F3 Nation REST API to ``PositionData`` and +``PositionWithAssignmentsData`` objects. + +Key endpoints used: + + GET /v1/position/org/{orgId} — org-specific positions (excludes global) + GET /v1/position/assignments/{orgId} — positions with assigned users for an org + GET /v1/position/id/{positionId} — single position by ID + POST /v1/position — create or update (crupdate; omit id to create) + DELETE /v1/position/id/{positionId} — soft delete + PUT /v1/position/assignments — replace all assignments for an org (deprecated + but kept for backward compatibility) +""" + +from __future__ import annotations + +from application.position import PositionData, PositionWithAssignmentsData, UserAssignmentData +from infrastructure.api_client.client import F3ApiClient, get_f3_api_client +from infrastructure.api_client.exceptions import F3ApiNotFoundError + + +def _parse_position(raw: dict) -> PositionData: + return PositionData( + id=raw["id"], + name=raw["name"], + description=raw.get("description"), + org_id=raw.get("orgId", raw.get("org_id")), + org_type=raw.get("orgType", raw.get("org_type")), + is_active=raw.get("isActive", raw.get("is_active", True)), + ) + + +def _parse_position_with_assignments(raw: dict) -> PositionWithAssignmentsData: + users_raw = raw.get("users") or [] + users = [ + UserAssignmentData( + user_id=u["id"], + f3_name=u.get("f3Name", u.get("f3_name")), + ) + for u in users_raw + ] + return PositionWithAssignmentsData( + id=raw["id"], + name=raw["name"], + description=raw.get("description"), + org_id=raw.get("orgId", raw.get("org_id")), + org_type=raw.get("orgType", raw.get("org_type")), + is_active=raw.get("isActive", raw.get("is_active", True)), + users=users, + ) + + +class ApiPositionRepository: + """Fetches and mutates positions and assignments via the F3 Nation REST API.""" + + def __init__(self, client: F3ApiClient) -> None: + self._client = client + + def get_by_org(self, org_id: int) -> list[PositionData]: + """Return org-specific positions (excludes global/national) for *org_id*.""" + result = self._client.get(f"/v1/position/org/{org_id}", params={"isActive": True}) + raw_list = result.get("positions") or result.get("results") or [] + return [_parse_position(p) for p in raw_list if p.get("isActive", p.get("is_active", True))] + + def get_assignments(self, org_id: int, region_org_id: int) -> list[PositionWithAssignmentsData]: + """Return all positions with their assigned users for *org_id*. + + *region_org_id* is passed as the ``regionOrgId`` query param so the API + returns the correct tier of positions (region-level vs AO-level). + """ + result = self._client.get( + f"/v1/position/assignments/{org_id}", + params={"regionOrgId": region_org_id}, + ) + raw_list = result.get("positions") or result.get("results") or [] + return [_parse_position_with_assignments(p) for p in raw_list] + + def get_by_id(self, position_id: int) -> PositionData | None: + try: + result = self._client.get(f"/v1/position/id/{position_id}") + except F3ApiNotFoundError: + return None + raw = result.get("position") or result.get("result") + return _parse_position(raw) if raw else None + + def create(self, name: str, description: str | None, org_id: int, org_type: str) -> PositionData: + result = self._client.post( + "/v1/position", + json={ + "name": name, + "description": description, + "orgId": org_id, + "orgType": org_type, + "isActive": True, + }, + ) + raw = result.get("position") or result.get("result") + return _parse_position(raw) if raw else PositionData(id=0, name=name) + + def update(self, position_id: int, name: str, description: str | None) -> None: + self._client.post( + "/v1/position", + json={ + "id": position_id, + "name": name, + "description": description, + }, + ) + + def delete(self, position_id: int) -> None: + self._client.delete(f"/v1/position/id/{position_id}") + + def update_all_assignments(self, org_id: int, assignments: list[dict]) -> None: + """Replace all position assignments for *org_id*. + + Uses the deprecated ``PUT /v1/position/assignments`` endpoint which + atomically replaces all assignments for an org in one call. + *assignments* format: ``[{"positionId": int, "userIds": [int, ...]}, ...]``. + """ + self._client.put( + "/v1/position/assignments", + json={"orgId": org_id, "assignments": assignments}, + ) + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- + +_repo: ApiPositionRepository | None = None + + +def get_api_position_repository() -> ApiPositionRepository: + global _repo + if _repo is None: + _repo = ApiPositionRepository(get_f3_api_client()) + return _repo diff --git a/apps/slackbot/infrastructure/api_client/series_repository.py b/apps/slackbot/infrastructure/api_client/series_repository.py new file mode 100644 index 00000000..f217855c --- /dev/null +++ b/apps/slackbot/infrastructure/api_client/series_repository.py @@ -0,0 +1,277 @@ +""" +API-backed implementation of ``SeriesRepository``. + +Maps responses from the F3 Nation REST API to ``SeriesData`` objects. + +Endpoints used: + GET /v1/event - list by region or AO (filter by regionIds / aoIds) + GET /v1/event/id/{id} - single series by ID + POST /v1/event - create or update (crupdate) + DELETE /v1/event/delete/{id} - soft-delete (cascades to future EventInstances) + +Cascade behaviour note: + The F3 Nation API automatically creates/updates/deletes future EventInstances + when a series (Event) is created, updated, or deleted. This application layer + therefore does NOT manage EventInstance records directly; all cascade logic is + handled server-side. + +API field notes: + - Response from GET /v1/event/id/{id} returns ``aos`` (list of {aoId, aoName}) + and ``regions`` (list of {regionId, regionName}), but NOT ``orgId`` directly. + - Response from POST /v1/event (crupdate) returns ``orgId`` directly. + - Response from GET /v1/event (list) returns ``parents`` (list of {parentId, parentName}) + for the AO and ``regions`` for the region. + - Neither the list nor single-by-ID endpoint returns event tag IDs. + ``SeriesData.event_tag_ids`` will always be ``[]`` when parsed from the API. +""" + +from __future__ import annotations + +from application.series import SeriesData +from infrastructure.api_client.client import F3ApiClient, get_f3_api_client +from infrastructure.api_client.exceptions import F3ApiNotFoundError + +_repo: "ApiSeriesRepository | None" = None + + +def _parse_series(raw: dict) -> SeriesData: + """Convert a raw API response dict to a ``SeriesData`` object.""" + # org_id: crupdate response → orgId; GET by ID → aos[0].aoId; list → parents[0].parentId + org_id = ( + raw.get("orgId") + or raw.get("org_id") + or (raw["aos"][0].get("aoId") if raw.get("aos") else None) + or (raw["parents"][0].get("parentId") if raw.get("parents") else None) + or 0 + ) + + # event_type_ids: list or GET by ID → eventTypes[{eventTypeId, ...}] + event_types_raw = raw.get("eventTypes", []) + if event_types_raw and isinstance(event_types_raw[0], dict): + event_type_ids = [ + t.get("eventTypeId") or t.get("id") for t in event_types_raw if t.get("eventTypeId") or t.get("id") + ] + elif event_types_raw: + event_type_ids = [int(t) for t in event_types_raw if t is not None] + else: + event_type_ids = [] + + # regions + regions = raw.get("regions", []) + region_id = regions[0].get("regionId") if regions else raw.get("regionId", raw.get("region_id")) + + return SeriesData( + id=raw["id"], + name=raw.get("name", ""), + description=raw.get("description"), + is_active=raw.get("isActive", raw.get("is_active", True)), + is_private=raw.get("isPrivate", raw.get("is_private", False)), + highlight=raw.get("highlight", False), + location_id=raw.get("locationId", raw.get("location_id")), + org_id=org_id, + region_id=region_id, + start_date=raw.get("startDate", raw.get("start_date")), + end_date=raw.get("endDate", raw.get("end_date")), + start_time=raw.get("startTime", raw.get("start_time")), + end_time=raw.get("endTime", raw.get("end_time")), + day_of_week=raw.get("dayOfWeek", raw.get("day_of_week")), + recurrence_pattern=raw.get("recurrencePattern", raw.get("recurrence_pattern")), + recurrence_interval=raw.get("recurrenceInterval", raw.get("recurrence_interval")), + index_within_interval=raw.get("indexWithinInterval", raw.get("index_within_interval")), + meta=raw.get("meta"), + event_type_ids=event_type_ids, + event_tag_ids=[], # API does not return event tag IDs for events + ) + + +def _build_crupdate_payload( + *, + series_id: int | None, + region_id: int, + ao_id: int, + name: str, + start_date: str, + start_time: str | None, + end_time: str | None, + day_of_week: str | None, + description: str | None, + location_id: int | None, + end_date: str | None, + recurrence_pattern: str | None, + recurrence_interval: int | None, + index_within_interval: int | None, + event_type_ids: list[int], + event_tag_ids: list[int], + is_active: bool, + is_private: bool, + highlight: bool, + meta: dict | None, +) -> dict: + payload: dict = { + "name": name, + "regionId": region_id, + "aoId": ao_id, + "isActive": is_active, + "highlight": highlight, + "startDate": start_date, + "isPrivate": is_private, + "eventTypeIds": event_type_ids, + } + if series_id is not None: + payload["id"] = series_id + if location_id is not None: + payload["locationId"] = location_id + if end_date is not None: + payload["endDate"] = end_date + if start_time is not None: + payload["startTime"] = start_time + if end_time is not None: + payload["endTime"] = end_time + if day_of_week is not None: + payload["dayOfWeek"] = day_of_week + if description is not None: + payload["description"] = description + if recurrence_pattern is not None: + payload["recurrencePattern"] = recurrence_pattern + if recurrence_interval is not None: + payload["recurrenceInterval"] = recurrence_interval + if index_within_interval is not None: + payload["indexWithinInterval"] = index_within_interval + if meta: + payload["meta"] = meta + if event_tag_ids: + payload["eventTagIds"] = event_tag_ids + return payload + + +class ApiSeriesRepository: + """Fetches and mutates event series (Events) via the F3 Nation REST API.""" + + def __init__(self, client: F3ApiClient) -> None: + self._client = client + + def get_by_region(self, region_id: int, ao_id: int | None = None) -> list[SeriesData]: + """Return active series for a region, optionally scoped to a single AO.""" + if ao_id is not None: + params: dict = {"aoIds": [ao_id], "statuses": ["active"]} + else: + params = {"regionIds": [region_id], "statuses": ["active"]} + result = self._client.get("/v1/event", params=params) + events_raw: list[dict] = result.get("events") or result.get("results") or [] + return [_parse_series(e) for e in events_raw] + + def get_by_id(self, series_id: int) -> SeriesData | None: + """Return a single series by primary key, or *None* if not found.""" + try: + result = self._client.get(f"/v1/event/id/{series_id}") + except F3ApiNotFoundError: + return None + raw = result.get("event") or result.get("result") + return _parse_series(raw) if raw else None + + def create( + self, + region_id: int, + ao_id: int, + name: str, + start_date: str, + start_time: str | None, + end_time: str | None, + day_of_week: str, + description: str | None, + location_id: int | None, + end_date: str | None, + recurrence_pattern: str | None, + recurrence_interval: int | None, + index_within_interval: int | None, + event_type_ids: list[int], + event_tag_ids: list[int], + is_active: bool, + is_private: bool, + highlight: bool, + meta: dict | None, + ) -> SeriesData: + """Create a new series; the API automatically generates future instances.""" + payload = _build_crupdate_payload( + series_id=None, + region_id=region_id, + ao_id=ao_id, + name=name, + start_date=start_date, + start_time=start_time, + end_time=end_time, + day_of_week=day_of_week, + description=description, + location_id=location_id, + end_date=end_date, + recurrence_pattern=recurrence_pattern, + recurrence_interval=recurrence_interval, + index_within_interval=index_within_interval, + event_type_ids=event_type_ids, + event_tag_ids=event_tag_ids, + is_active=is_active, + is_private=is_private, + highlight=highlight, + meta=meta, + ) + result = self._client.post("/v1/event", json=payload) + raw = result.get("event") or result.get("result") + return _parse_series(raw) + + def update( + self, + series_id: int, + region_id: int, + ao_id: int, + name: str, + start_date: str, + start_time: str | None, + end_time: str | None, + description: str | None, + location_id: int | None, + end_date: str | None, + event_type_ids: list[int], + event_tag_ids: list[int], + is_active: bool, + is_private: bool, + highlight: bool, + meta: dict | None, + ) -> SeriesData: + """Update an existing series; the API cascades changes to future instances.""" + payload = _build_crupdate_payload( + series_id=series_id, + region_id=region_id, + ao_id=ao_id, + name=name, + start_date=start_date, + start_time=start_time, + end_time=end_time, + day_of_week=None, # day_of_week is immutable on edit (form doesn't expose it) + description=description, + location_id=location_id, + end_date=end_date, + recurrence_pattern=None, # recurrence fields immutable on edit + recurrence_interval=None, + index_within_interval=None, + event_type_ids=event_type_ids, + event_tag_ids=event_tag_ids, + is_active=is_active, + is_private=is_private, + highlight=highlight, + meta=meta, + ) + result = self._client.post("/v1/event", json=payload) + raw = result.get("event") or result.get("result") + return _parse_series(raw) + + def delete(self, series_id: int) -> None: + """Soft-delete a series; future instances are cascade-deleted by the API.""" + self._client.delete(f"/v1/event/delete/{series_id}") + + +def get_api_series_repository() -> ApiSeriesRepository: + """Return the module-level singleton ``ApiSeriesRepository``.""" + global _repo + if _repo is None: + _repo = ApiSeriesRepository(get_f3_api_client()) + return _repo diff --git a/apps/slackbot/main.py b/apps/slackbot/main.py new file mode 100644 index 00000000..7a920338 --- /dev/null +++ b/apps/slackbot/main.py @@ -0,0 +1,215 @@ +import json +import logging +import os +import re +import threading +import time +import traceback +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Callable, Tuple + +import functions_framework +from dotenv import load_dotenv +from flask import Request, Response +from google.cloud.logging_v2.handlers import StructuredLogHandler, setup_logging +from slack_bolt import Ack, App +from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler +from slack_bolt.adapter.socket_mode import SocketModeHandler +from slack_sdk.web import WebClient + +import scripts +from features import strava +from utilities.builders import add_debug_form, add_loading_form, send_error_response +from utilities.constants import ENABLE_DEBUGGING, LOCAL_DEVELOPMENT, SOCKET_MODE +from utilities.database.orm import SlackSettings +from utilities.helper_functions import ( + get_oauth_settings, + get_region_record, + get_request_type, + safe_get, + update_local_region_records, +) +from utilities.routing import MAIN_MAPPER +from utilities.slack.actions import LOADING_ID + + +def setup_debugger(): + try: + # Only ever enable debugger in local development + if not (LOCAL_DEVELOPMENT and ENABLE_DEBUGGING): + return + + import debugpy + + debugpy.listen(("0.0.0.0", 5678)) + print("Waiting for debugger attach on port 5678...") + debugpy.wait_for_client() + except Exception as exc: # pragma: no cover - best-effort debug helper + logging.getLogger().warning(f"Failed to initialize debugpy: {exc}") + + +setup_debugger() + +load_dotenv() + +process_before_response = os.environ.get("PROCESS_BEFORE_RESPONSE", "false").lower() == "true" +logging_level = logging.DEBUG if os.environ.get("LOG_LEVEL", "INFO").upper() == "DEBUG" else logging.INFO +if LOCAL_DEVELOPMENT: + logger = logging.getLogger() + logger.setLevel(logging_level) + handler = logging.StreamHandler() + logger.addHandler(handler) +else: + handler = StructuredLogHandler() + setup_logging(handler, log_level=logging_level) + +app = App( + process_before_response=process_before_response, + oauth_settings=get_oauth_settings(), +) + +# ---------------------------------------- +# Production Mode: Google Cloud Function HTTP Handler +# (DISABLED in local development) +# ---------------------------------------- +if not LOCAL_DEVELOPMENT: + + @functions_framework.http + def handler(request: Request): + if request.path == "/": + return Response("Service is running", status=200, headers={"Access-Control-Allow-Origin": "*"}) + elif request.path == "/gcp_event": + logging.info("GCP Event") + return scripts.handle(request) + elif request.path == "/exchange_token": + logging.info("Strava token exchange request") + logging.info(f"Request query parameters: {request.args}") + logging.info(f"Request headers: {request.headers}") + logging.info(f"Request body: {request.get_data(as_text=True)}") + return strava.strava_exchange_token(request) + elif request.path[:6] == "/slack": + slack_handler = SlackRequestHandler(app=app) + return slack_handler.handle(request) + elif request.path == "/hourly-runner-complete": + update_local_region_records() + return Response("Hourly runner completion endpoint", status=200) + else: + return Response(f"Invalid path: {request.path}", status=404) + + +def main_response(body: dict, logger: logging.Logger, client: WebClient, ack: Ack, context: dict): + request_type, request_id = get_request_type(body) + + if LOCAL_DEVELOPMENT: + logger.info(json.dumps(body, indent=4)) + else: + logger.info(body) + + team_id = safe_get(body, "team_id") or safe_get(body, "team", "id") + + lookup: Tuple[Callable, bool] = safe_get(safe_get(MAIN_MAPPER, request_type), request_id) + + if lookup: + run_function, add_loading = lookup + if ENABLE_DEBUGGING and request_type != "view_submission": + body[LOADING_ID] = add_debug_form(body=body, client=client) + # NOTE: do not put debugging breakpoints above this line + elif add_loading: + body[LOADING_ID] = add_loading_form(body=body, client=client) + + if request_type != "block_suggestion": + ack() + + try: + try: + region_record: SlackSettings = get_region_record(team_id, body, context, client, logger) + except Exception as exc: + logger.warning(f"Error getting region record: {exc}") + region_record = SlackSettings(team_id=safe_get(body, "team_id") or safe_get(body, "team", "id")) + # time the call + start_time = time.time() + resp = run_function( + body=body, + client=client, + logger=logger, + context=context, + region_record=region_record, + ) + if request_type == "block_suggestion": + ack(options=resp if resp is not None else []) + # elif request_type == "view_submission": + # update_submit_modal( + # client=client, logger=logger, text="Your data was saved successfully!" + # ) # TODO: handle errors + end_time = time.time() + logger.info(f"Function {run_function.__name__} took {end_time - start_time:.2f} seconds to run.") + except Exception as exc: + tb_str = "".join(traceback.format_exception(None, exc, exc.__traceback__)) + send_error_response(body=body, client=client, error=str(exc)[:3000]) + logger.error(tb_str) + else: + ack() + logger.warning( + f"no handler for path: " + f"{safe_get(safe_get(MAIN_MAPPER, request_type), request_id) or request_type + ', ' + request_id}" + ) + + +ARGS = [main_response] +LAZY_KWARGS = {} + +MATCH_ALL_PATTERN = re.compile(".*") +app.action(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) +app.view(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) +app.command(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) +app.view_closed(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) +app.event(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) +app.options(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) +app.shortcut(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) + + +def start_local_health_server(port: int): + class HealthHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + self.wfile.write(b"f3 slackbot local socket mode is running") + + # Keep local dev logs focused on bot activity. + def log_message(self, fmt, *args): + return + + server = HTTPServer(("0.0.0.0", port), HealthHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return server + +if __name__ == "__main__": + port = 8080 + local_http_port = int(os.environ.get("LOCAL_HTTP_PORT", "3006")) + + if LOCAL_DEVELOPMENT and not SOCKET_MODE: + raise RuntimeError("Local development requires SOCKET_MODE=true.") + + if not SOCKET_MODE: + try: + app.start(port=port) + update_local_region_records() + except KeyboardInterrupt: + # graceful shutdown during auto-reload + pass + else: + if LOCAL_DEVELOPMENT: + start_local_health_server(local_http_port) + print(f"Local HTTP health server listening on http://localhost:{local_http_port}", flush=True) + + print("Running in local Socket Mode.", flush=True) + + # Ensure SLACK_APP_TOKEN is present + app_token = os.environ.get("SLACK_APP_TOKEN") + if not app_token: + raise RuntimeError("SLACK_APP_TOKEN missing. Check your .env file.") + + handler = SocketModeHandler(app, app_token) + handler.start() diff --git a/apps/slackbot/package.json b/apps/slackbot/package.json new file mode 100644 index 00000000..74f8bb76 --- /dev/null +++ b/apps/slackbot/package.json @@ -0,0 +1,10 @@ +{ + "name": "slack-bot", + "version": "1.13.0", + "private": true, + "scripts": { + "dev": "uv run bash ./app_startup.sh", + "test": "uv run pytest", + "lint": "uv run ruff check ." + } +} diff --git a/apps/slackbot/pyproject.toml b/apps/slackbot/pyproject.toml new file mode 100644 index 00000000..66e2ac6f --- /dev/null +++ b/apps/slackbot/pyproject.toml @@ -0,0 +1,103 @@ +[tool.ruff] +line-length = 120 + +select = [ + "E", # pycodestyle errors (settings from FastAPI, thanks, @tiangolo!) + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "C", # flake8-comprehensions + "B", # flake8-bugbear +] +ignore = [ + "C901", # too complex +] + +[tool.ruff.isort] +order-by-type = true +relative-imports-order = "closest-to-furthest" +extra-standard-library = ["typing"] +section-order = [ + "future", + "standard-library", + "third-party", + "first-party", + "local-folder", +] +known-first-party = [] + +[project] +name = "f3-nation-slack-bot" +version = "1.13.0" +description = "" +authors = [{ name = "Evan Petzoldt", email = "evan.petzoldt@protonmail.com" }] +readme = "README.md" +requires-python = ">=3.12,<4.0" +dependencies = [ + "slack-bolt>=1.18.1,<2.0.0", + "datetime>=5.5,<6.0.0", + "cryptography>=46.0.5", + "sqlalchemy>=2.0.28,<3.0.0", + "requests>=2.31.0,<3.0.0", + "requests-oauthlib>=1.3.1,<2.0.0", + "sqlalchemy-utils>=0.41.1,<0.42.0", + "psycopg2-binary>=2.9.9,<3.0.0", + "functions-framework>=3.8.1,<4.0.0", + "pg8000>=1.31.5,<2.0.0", + "cloud-sql-python-connector>=1.20.0,<2.0.0", + "google-cloud-logging>=3.11.0,<4.0.0", + "f3-data-models", + "pre-commit>=4.2.0,<5.0.0", + "pillow>=12.2.0", + "pillow-heif>=0.15.0,<0.16.0", + "watchfiles>=1.1.1,<2.0.0", + "python-dotenv>=1.2.1,<2.0.0", + "sendgrid>=6.11.0,<7.0.0", +] + +[dependency-groups] +dev = [ + "sqlalchemy-schemadisplay>=2.0,<3.0", + "graphviz>=0.20.3,<0.21.0", + "ipykernel>=6.30.1,<7.0.0", + "kaleido>=1.1.0,<2.0.0", + "mplcyberpunk>=0.7.6,<0.8.0", + "matplotlib>=3.10.7,<4.0.0", + "playwright>=1.44.0,<2.0.0", + "dataframe-image>=0.2.7,<0.3.0", + "pandas>=2.2.2,<3.0.0", + "alembic>=1.13.0,<2.0.0", + "debugpy>=1.8.17,<2.0.0", + "commitizen>=4.13.9,<5.0.0", + "python-semantic-release>=10.5.3,<11.0.0", +] + +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] + +upload_to_release = false +upload_to_pypi = false + +[tool.semantic_release.branches.main] +match = "prod" +prerelease = false + +[tool.semantic_release.changelog] +changelog_file = "CHANGELOG.md" + +[tool.semantic_release.commit_parser_options] +# These tags define what triggers a version bump +minor_tags = ["feat"] +patch_tags = ["fix", "perf"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["application*", "features*", "infrastructure*", "utilities*"] +exclude = ["tests*", "assets*", "scripts*"] diff --git a/apps/slackbot/scripts/Dockerfile b/apps/slackbot/scripts/Dockerfile new file mode 100644 index 00000000..9703bf4f --- /dev/null +++ b/apps/slackbot/scripts/Dockerfile @@ -0,0 +1,49 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 APP_HOME=/app +WORKDIR $APP_HOME + +# Install system dependencies for Playwright/Chromium +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + ca-certificates \ + gnupg \ + libglib2.0-0 \ + libgdk-pixbuf-2.0-0 \ + libnss3 \ + libnspr4 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + libgbm1 \ + libasound2 \ + libpangocairo-1.0-0 \ + libpango-1.0-0 \ + libcairo2 \ + libexpat1 \ + libdbus-1-3 \ + libx11-xcb1 \ + libxext6 \ + libxfixes3 \ + libxcb1 \ + libsm6 \ + libxtst6 \ + fonts-liberation \ + libatspi2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +COPY scripts/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +RUN pip install --no-cache-dir playwright +RUN python -m playwright install chromium + +COPY . ./ + +CMD ["python", "-m", "scripts.hourly_runner"] \ No newline at end of file diff --git a/apps/slackbot/scripts/README.md b/apps/slackbot/scripts/README.md new file mode 100644 index 00000000..afdd8d92 --- /dev/null +++ b/apps/slackbot/scripts/README.md @@ -0,0 +1,50 @@ +# Scripts Module + +This directory contains the scripts and automation jobs for the F3 Nation Slack Bot project. These scripts are designed to be run as a Cloud Run Job or manually for scheduled or batch operations (such as hourly reporting, reminders, and data updates). + +## Structure + +- `Dockerfile` — Dockerfile for building the scripts container image (includes all heavy dependencies) +- `requirements.txt` — Python dependencies for scripts (can include plotting, Playwright, pandas, etc.) +- `hourly_runner.py` — Entrypoint for running all hourly scripts +- Other Python scripts for specific automation tasks + +## How to Build the Scripts Image + +1. **Navigate to this directory:** + + ```sh + cd scripts + ``` + +2. **Build the Docker image:** + ```sh + gcloud builds submit --tag us-central1-docker.pkg.dev///: . + ``` + + - Replace ``, ``, ``, and `` with your GCP project, Artifact Registry repo, image name, and tag. + - I used `gcloud builds submit --tag us-central1-docker.pkg.dev/f3slackbot/f3-bot-scripts/f3-bot-scripts:v0.1.0 .` + +## How to Run Locally + +1. **Install dependencies:** + ```sh + pip install -r requirements.txt + ``` +2. **Run the hourly runner:** + ```sh + python -m scripts.hourly_runner + ``` + + - You can pass arguments like `--force` or `--skip-reporting` as needed. + +## How It Works + +- The main entrypoint is `hourly_runner.py`, which coordinates the execution of all scheduled scripts. +- Each script is responsible for a specific automation task (e.g., reminders, reporting, Slack updates). +- The Dockerfile ensures all system and Python dependencies are available for headless browser and data processing tasks. + +## Notes + +- This image is intended for Cloud Run Jobs and includes heavy dependencies not needed by the main app. +- Keep the main app's Dockerfile and requirements.txt in the project root for a slim deployment. diff --git a/apps/slackbot/scripts/__init__.py b/apps/slackbot/scripts/__init__.py new file mode 100644 index 00000000..a9f1e854 --- /dev/null +++ b/apps/slackbot/scripts/__init__.py @@ -0,0 +1,36 @@ +import base64 +import json + +from flask import Request, Response + +from scripts import hourly_runner + + +def handle(request: Request) -> Response: + decoded_data = request.data.decode() + data_dict = json.loads(decoded_data) + event_message = base64.b64decode(data_dict["message"]["data"]).decode() + + try: + if event_message == "hourly": + # Acknowledge immediately with 200 response + import threading + + def run_scripts(): + # ...existing code... + try: + hourly_runner.run_all_hourly_scripts() + except Exception as e: + print(f"Error running hourly scripts: {e}") + + # Start scripts in background thread + thread = threading.Thread(target=run_scripts) + thread.daemon = True + thread.start() + + return Response("Hourly scripts started", status=200) + else: + return Response(f"Event message not used: {event_message}", status=200) + except Exception as e: + print(f"Error running scripts: {e}") + return Response(f"Error: {e}", status=200) diff --git a/apps/slackbot/scripts/auto_preblast_send.py b/apps/slackbot/scripts/auto_preblast_send.py new file mode 100644 index 00000000..68216140 --- /dev/null +++ b/apps/slackbot/scripts/auto_preblast_send.py @@ -0,0 +1,173 @@ +import os +import ssl +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import List + +import pytz +from f3_data_models.models import ( + Attendance, + Attendance_x_AttendanceType, + EventInstance, + EventType, + EventType_x_EventInstance, + Org, + Org_x_SlackSpace, + Series_Exception, + SlackSpace, + SlackUser, + User, +) +from f3_data_models.utils import get_session +from slack_sdk.web import WebClient +from sqlalchemy import and_, func, select +from sqlalchemy.orm import aliased + +from features.calendar import event_preblast +from utilities.database.orm import SlackSettings +from utilities.helper_functions import current_date_cst, safe_get + + +@dataclass +class PreblastItem: + event: EventInstance + event_type: EventType + org: Org + parent_org: Org + q_name: str + slack_user_id: str + q_avatar_url: str + slack_settings: SlackSettings + + +@dataclass +class PreblastList: + items: List[PreblastItem] = field(default_factory=list) + + def pull_data(self, filters: List): + session = get_session() + ParentOrg = aliased(Org) + + firstq_subquery = ( + select( + Attendance.event_instance_id, + Attendance.user_id, + func.row_number() + .over(partition_by=Attendance.event_instance_id, order_by=Attendance.created) + .label("rn"), + ) + .select_from(Attendance) + .join(Attendance_x_AttendanceType, Attendance.id == Attendance_x_AttendanceType.attendance_id) + .filter(Attendance_x_AttendanceType.attendance_type_id == 2) + .alias() + ) + + query = ( + session.query( + EventInstance, + EventType, + Org, + ParentOrg, + User.f3_name.label("q_name"), + SlackUser.slack_id, + User.avatar_url.label("q_avatar_url"), + SlackSpace.settings, + ) + .select_from(EventInstance) + .join(Org, Org.id == EventInstance.org_id) + .join(EventType_x_EventInstance, EventType_x_EventInstance.event_instance_id == EventInstance.id) + .join(EventType, EventType.id == EventType_x_EventInstance.event_type_id) + .join(ParentOrg, Org.parent_id == ParentOrg.id) + .outerjoin( + firstq_subquery, + and_(EventInstance.id == firstq_subquery.c.event_instance_id, firstq_subquery.c.rn == 1), + ) + .outerjoin(User, User.id == firstq_subquery.c.user_id) + .join(Org_x_SlackSpace, ParentOrg.id == Org_x_SlackSpace.org_id) + .join(SlackSpace, Org_x_SlackSpace.slack_space_id == SlackSpace.id) + .outerjoin(SlackUser, and_(User.id == SlackUser.user_id, SlackUser.slack_team_id == SlackSpace.team_id)) + .filter(*filters) + .order_by(ParentOrg.name, Org.name, EventInstance.start_time) + ) + records = query.all() + self.items = [ + PreblastItem( + event=r[0], + event_type=r[1], + org=r[2], + parent_org=r[3], + q_name=r[4], + slack_user_id=r[5], + q_avatar_url=r[6], + slack_settings=SlackSettings(**r[7]), + ) + for r in records + ] + session.expunge_all() + session.close() + + +def send_automated_preblasts(force: bool = False): + # get the current time in US/Central timezone + current_time = datetime.now(pytz.timezone("US/Central")) + preblast_list = PreblastList() + preblast_list.pull_data( + filters=[ + EventInstance.start_date == current_date_cst() + timedelta(days=1), # eventually configurable + EventInstance.preblast_ts.is_(None), # not already sent + EventInstance.is_active, # not canceled + ] + ) + + for preblast in preblast_list.items: + # Respect per-event opt-out + if safe_get((preblast.event.meta or {}), "do_not_send_auto_preblasts"): + continue + + automated_option = safe_get(preblast.slack_settings.__dict__, "automated_preblast_option") or "disable" + automated_hour = safe_get(preblast.slack_settings.__dict__, "automated_preblast_hour_cst") + scheduled_hour = safe_get(preblast.slack_settings.__dict__, "scheduled_preblast_hour_cst") or automated_hour + + # Skip if feature is disabled + if automated_option == "disable": + continue + + # If an hour is configured, require match to current hour + if preblast.event.preblast: + # If the preblast text is already set, the Q already scheduled it + if scheduled_hour is not None and scheduled_hour != current_time.hour and not force: + continue + else: + # If not, then use the automated preblast hour + if automated_hour is not None and automated_hour != current_time.hour and not force: + continue + + # Respect option semantics around Q assignment + if automated_option == "q_only" and not preblast.q_name: + continue + + # Do not send for closed events + if preblast.event.series_exception == Series_Exception.closed: + continue + + try: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + slack_client = WebClient(preblast.slack_settings.bot_token, ssl=ssl_context) + event_preblast.send_preblast( + event_instance_id=preblast.event.id, + region_record=preblast.slack_settings, + client=slack_client, + ) + except Exception as e: + print(f"Error sending preblast for event {preblast.event.id}: {e}") + continue + + +if __name__ == "__main__": + send_automated_preblasts(force=True) diff --git a/apps/slackbot/scripts/award_achievements.py b/apps/slackbot/scripts/award_achievements.py new file mode 100755 index 00000000..4ae5b5c9 --- /dev/null +++ b/apps/slackbot/scripts/award_achievements.py @@ -0,0 +1,802 @@ +"""Daily (idempotent) auto-award achievements script. + +Usage (from repo root, with env + poetry activated): + poetry run python scripts/award_achievements.py --dry-run + +Core logic: + 1. Load active, auto_award achievements + 2. For each achievement + each relevant (award_year, award_period) within the current year + (or lifetime), compute the metric defined by auto_threshold_type subject to auto_filters + 3. Identify users whose metric >= auto_threshold + 4. Respect region scoping (specific_org_id => user.home_region_id must match) + 5. Insert missing rows into achievements_x_users (award_year/period keyed) (unless --dry-run) + +Idempotent: already-awarded combinations are skipped. + +Assumptions / notes: + - award_year == calendar year (UTC) for all non-lifetime cadences + - weekly periods use ISO week numbers (1..53); monthly 1..12; quarterly 1..4; yearly single period 1 + - lifetime uses award_year = -1, award_period = -1 (per model defaults) + - auto_filters structure: {"include": [{"event_type_id": [..]}, {"event_tag_id": [..]}], + "exclude": [...] } + * We treat each dict in include as a dimension constraint (AND across dimensions, OR within values) + * Exclude removes events matching ANY of its value lists. + - Supported threshold types: 'posts' (attendance count), + 'unique_aos' (distinct EventInstanceExpanded.ao_org_id), + 'qs' (number of times user Q'd), + 'posts_at_ao' (attendance at a specific AO or any AO) + - Additional threshold types can be added by extending _build_metric_clause. + +If filter keys are unrecognised, they are ignored (logged at DEBUG level). +""" + +from __future__ import annotations + +import os +import ssl +import sys + +import pytz + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +import argparse +from collections import defaultdict +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta +from typing import Any, Dict, Iterable, List, Sequence, Tuple + +from f3_data_models.models import Achievement, Achievement_x_User, Org_x_SlackSpace, SlackSpace, SlackUser, User +from f3_data_models.utils import get_session +from slack_sdk.web import WebClient +from sqlalchemy import Integer, and_, distinct, func, literal, select, tuple_ +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.orm import Session + +from utilities import constants +from utilities.database.orm import SlackSettings +from utilities.database.orm.views import EventAttendance, EventInstanceExpanded + +# --------------------------------------------------------------------------- +# Period calculations +# --------------------------------------------------------------------------- + + +def _daterange_year(year: int) -> Tuple[date, date]: + return date(year, 1, 1), date(year, 12, 31) + + +def _week_start(d: date) -> date: + return d - timedelta(days=d.weekday()) # Monday + + +def _iso_week_range(year: int, iso_week: int) -> Tuple[date, date]: + # ISO weeks: find first week containing Jan 4 (always week 1) + jan4 = date(year, 1, 4) + week1_start = _week_start(jan4) + start = week1_start + timedelta(weeks=iso_week - 1) + end = start + timedelta(days=6) + return start, end + + +def _month_range(year: int, month: int) -> Tuple[date, date]: + if month == 12: + return date(year, 12, 1), date(year, 12, 31) + start = date(year, month, 1) + next_month = date(year + (month // 12), (month % 12) + 1, 1) + return start, next_month - timedelta(days=1) + + +def _quarter_range(year: int, quarter: int) -> Tuple[date, date]: + start_month = (quarter - 1) * 3 + 1 + start = date(year, start_month, 1) + end_month = start_month + 2 + end = _month_range(year, end_month)[1] + return start, end + + +def iter_periods(cadence: str, year: int, today: date) -> Iterable[Tuple[int, Tuple[date, date]]]: + """Yield (period_number, (start, end)) for all periods in [start_of_year, today].""" + if cadence == "weekly": + current_week = today.isocalendar().week + for w in range(1, current_week + 1): + yield w, _iso_week_range(year, w) + elif cadence == "monthly": + for m in range(1, today.month + 1): + yield m, _month_range(year, m) + elif cadence == "quarterly": + current_quarter = (today.month - 1) // 3 + 1 + for q in range(1, current_quarter + 1): + yield q, _quarter_range(year, q) + elif cadence == "yearly": + # Single period = 1 spanning the year to date + start, _ = _daterange_year(year) + yield 1, (start, today) + elif cadence == "lifetime": + # Represent no bounded date range (None, None) sentinel + yield -1, (date.min, date.max) + else: + raise ValueError(f"Unsupported cadence: {cadence}") + + +# --------------------------------------------------------------------------- +# Filter & metric helpers +# --------------------------------------------------------------------------- + + +SUPPORTED_THRESHOLD_TYPES = {"posts", "unique_aos", "qs", "posts_at_ao"} + + +def _apply_filters(base_filters: list, auto_filters: Dict[str, Any]) -> tuple[list, bool, bool, list, list]: + """Return (filters, need_type_join, need_tag_join, exclude_type_ids, exclude_tag_ids).""" + if not auto_filters: + return base_filters, False, False, [], [] + includes = auto_filters.get("include") or [] + excludes = auto_filters.get("exclude") or [] + + include_type_ids: set[int] = set() + include_tag_ids: set[int] = set() + exclude_type_ids: set[int] = set() + exclude_tag_ids: set[int] = set() + + # Collect include constraints + for inc in includes: + if not isinstance(inc, dict): + continue + type_ids = inc.get("event_type_id") or [] + tag_ids = inc.get("event_tag_id") or [] + include_type_ids.update([tid for tid in type_ids if isinstance(tid, int)]) + include_tag_ids.update([tg for tg in tag_ids if isinstance(tg, int)]) + first_f_ind = inc.get("first_f_ind") + if first_f_ind is not None: + base_filters.append(EventInstanceExpanded.first_f_ind == first_f_ind) + print(f"Applying include first_f_ind filter: {first_f_ind}") + second_f_ind = inc.get("second_f_ind") + if second_f_ind is not None: + base_filters.append(EventInstanceExpanded.second_f_ind == second_f_ind) + third_f_ind = inc.get("third_f_ind") + if third_f_ind is not None: + base_filters.append(EventInstanceExpanded.third_f_ind == third_f_ind) + ao_org_id = inc.get("ao_org_id") + if ao_org_id is not None: + base_filters.append(EventInstanceExpanded.ao_org_id == ao_org_id) + + # Collect exclude constraints + for exc in excludes: + if not isinstance(exc, dict): + continue + type_ids = exc.get("event_type_id") or [] + tag_ids = exc.get("event_tag_id") or [] + exclude_type_ids.update([tid for tid in type_ids if isinstance(tid, int)]) + exclude_tag_ids.update([tg for tg in tag_ids if isinstance(tg, int)]) + first_f_ind = exc.get("first_f_ind") + if first_f_ind is not None: + base_filters.append(EventInstanceExpanded.first_f_ind != first_f_ind) + second_f_ind = exc.get("second_f_ind") + if second_f_ind is not None: + base_filters.append(EventInstanceExpanded.second_f_ind != second_f_ind) + third_f_ind = exc.get("third_f_ind") + if third_f_ind is not None: + base_filters.append(EventInstanceExpanded.third_f_ind != third_f_ind) + ao_org_id = exc.get("ao_org_id") + if ao_org_id is not None: + base_filters.append(EventInstanceExpanded.ao_org_id != ao_org_id) + + # When using the flattened views, we no longer need separate joins + # for type / tag link tables, so the booleans are always False. + # For now, event_type_id / event_tag_id-based filters are not + # supported directly against the view and are ignored (but the + # first/second/third_f_ind filters above are applied normally). + + return base_filters, False, False, list(exclude_type_ids), list(exclude_tag_ids) + + +def _build_metric_columns(threshold_type: str): + if threshold_type == "posts": + return func.count(EventAttendance.id) + if threshold_type == "unique_aos": + return func.count(distinct(EventInstanceExpanded.ao_org_id)) + if threshold_type == "qs": + # Number of times the user Q'd (q_ind is 1 for Q, 0/NULL otherwise) + return func.coalesce(func.sum(EventAttendance.q_ind), 0) + if threshold_type == "posts_at_ao": + # Posts scoped by AO via auto_filters (ao_org_id); when no ao_org_id + # filter is supplied, this becomes equivalent to total posts. + return func.count(EventAttendance.id) + raise ValueError(f"Unsupported auto_threshold_type: {threshold_type}") + + +def _compute_all_period_metrics( + session: Session, achievement: Achievement, threshold_type: str, today: date +) -> list[tuple[int, int, int]]: + """Return list of (user_id, award_year, award_period, metric). + + Single query per achievement to cover all elapsed periods in current year (or lifetime). + """ + print(f"Computing metrics for achievement={achievement.id} ({threshold_type})...") + metric_col = _build_metric_columns(threshold_type) + cadence = str(achievement.auto_cadence.name).lower() + + # Lifetime: group to a fixed (-1, -1) + if cadence == "lifetime": + filters: list = [] + filters, need_type_join, need_tag_join, *_ = _apply_filters([], achievement.auto_filters or {}) + if achievement.specific_org_id: + filters.append(EventAttendance.home_region_id == achievement.specific_org_id) + query = select( + EventAttendance.user_id.label("user_id"), + func.cast(literal(-1), Integer).label("award_year"), + func.cast(literal(-1), Integer).label("award_period"), + metric_col.label("metric"), + ).join( + EventInstanceExpanded, + EventInstanceExpanded.id == EventAttendance.event_instance_id, + ) + query = query.filter(*filters).group_by("user_id") + return [(r[0], r[1], r[2], int(r[3])) for r in session.execute(query).all()] + + # Non-lifetime: restrict to year start..today + year = today.year + start_year = date(year, 1, 1) + filters: list = [and_(EventInstanceExpanded.start_date >= start_year, EventInstanceExpanded.start_date <= today)] + filters, need_type_join, need_tag_join, *_ = _apply_filters(filters, achievement.auto_filters or {}) + if achievement.specific_org_id: + filters.append(EventAttendance.home_region_id == achievement.specific_org_id) + + # Period expression + if cadence == "weekly": + period_expr = func.extract("week", EventInstanceExpanded.start_date) + elif cadence == "monthly": + period_expr = func.extract("month", EventInstanceExpanded.start_date) + elif cadence == "quarterly": + period_expr = (func.extract("month", EventInstanceExpanded.start_date) - 1) / 3 + 1 + elif cadence == "yearly": + period_expr = 1 + else: + raise ValueError(f"Unsupported cadence: {cadence}") + + query = select( + EventAttendance.user_id.label("user_id"), + func.cast(year, Integer).label("award_year"), + func.cast(period_expr, Integer).label("award_period"), + metric_col.label("metric"), + ).join( + EventInstanceExpanded, + EventInstanceExpanded.id == EventAttendance.event_instance_id, + ) + query = query.filter(*filters).group_by("user_id", "award_period") + rows = session.execute(query).all() + # Filter out future periods (e.g., if partial query produced future periods due to date overlap) - defensive + valid_rows: list[tuple[int, int, int, int]] = [] + if cadence == "weekly": + current_week = today.isocalendar().week + for r in rows: + if 1 <= r[2] <= current_week: + valid_rows.append((r[0], r[1], r[2], int(r[3]))) + elif cadence == "monthly": + for r in rows: + if 1 <= r[2] <= today.month: + valid_rows.append((r[0], r[1], r[2], int(r[3]))) + elif cadence == "quarterly": + current_q = (today.month - 1) // 3 + 1 + for r in rows: + if 1 <= r[2] <= current_q: + valid_rows.append((r[0], r[1], r[2], int(r[3]))) + elif cadence == "yearly": + for r in rows: + if r[2] == 1: + valid_rows.append((r[0], r[1], r[2], int(r[3]))) + return valid_rows + + +# --------------------------------------------------------------------------- +# Core awarding logic +# --------------------------------------------------------------------------- + + +@dataclass +class CandidateAward: + achievement_id: int + user_id: int + award_year: int + award_period: int + metric: int + + +def _existing_awards(session: Session, achievement_id: int, year: int, period: int) -> set[int]: + q = select(Achievement_x_User.user_id).filter( + Achievement_x_User.achievement_id == achievement_id, + Achievement_x_User.award_year == year, + Achievement_x_User.award_period == period, + ) + return {r[0] for r in session.execute(q).all()} + + +def process_achievement(session: Session, achievement: Achievement, today: date) -> List[CandidateAward]: + if not achievement.auto_award or not achievement.is_active: + return [] + if not achievement.auto_threshold or not achievement.auto_threshold_type: + return [] + + threshold_type = str(achievement.auto_threshold_type).lower() + if threshold_type not in SUPPORTED_THRESHOLD_TYPES: + return [] + + all_rows = _compute_all_period_metrics(session, achievement, threshold_type, today) + if not all_rows: + return [] + + # Collect unique (year, period) + period_keys = {(r[1], r[2]) for r in all_rows} + + # Prefetch all existing awards for this achievement & these periods + existing_map: dict[tuple[int, int], set[int]] = {k: set() for k in period_keys} + period_list = list(period_keys) + if period_list: + existing_query = ( + select( + Achievement_x_User.user_id, + Achievement_x_User.award_year, + Achievement_x_User.award_period, + ) + .filter(Achievement_x_User.achievement_id == achievement.id) + .filter(tuple_(Achievement_x_User.award_year, Achievement_x_User.award_period).in_(list(period_keys))) + ) + # Note: Using period_keys directly inside IN since SQLAlchemy will expand tuple set properly. + # If dialect issues arise, replace with list(year_period_tuples). + for user_id, y, p in session.execute(existing_query).all(): + existing_map.setdefault((y, p), set()).add(user_id) + + awards: list[CandidateAward] = [] + for user_id, award_year, award_period, metric in all_rows: + if metric >= achievement.auto_threshold and user_id not in existing_map.get((award_year, award_period), set()): + awards.append( + CandidateAward( + achievement_id=achievement.id, + user_id=user_id, + award_year=award_year, + award_period=award_period, + metric=metric, + ) + ) + return awards + + +def award_candidates(session: Session, candidates: Sequence[CandidateAward], dry_run: bool) -> None: + """Persist awards efficiently. + + Uses a single bulk INSERT .. ON CONFLICT DO NOTHING for Postgres to avoid per-row round trips. + Falls back to no-op when dry-run. + """ + if not candidates: + return + if dry_run: + for c in candidates: + print( + "[DRY-RUN] Would award achievement=" + f"{c.achievement_id} user={c.user_id} year={c.award_year} " + f"period={c.award_period} metric={c.metric}" + ) + print(f"[DRY-RUN] Total new awards: {len(candidates)}") + return + + now = datetime.now(UTC).date() + rows = [ + { + "achievement_id": c.achievement_id, + "user_id": c.user_id, + "award_year": c.award_year, + "award_period": c.award_period, + "date_awarded": now, + } + for c in candidates + ] + + # Bulk insert with ON CONFLICT DO NOTHING (composite PK prevents duplicates in races) + stmt = pg_insert(Achievement_x_User).values(rows) + stmt = stmt.on_conflict_do_nothing( + index_elements=[ + Achievement_x_User.achievement_id, + Achievement_x_User.user_id, + Achievement_x_User.award_year, + Achievement_x_User.award_period, + ] + ) + result = session.execute(stmt) + session.commit() + + inserted = result.rowcount if result.rowcount is not None else 0 + print(f"Inserted {inserted} new achievement awards (requested {len(rows)}).") + if inserted < len(rows): + print("Some awards already existed and were skipped.") + + +# --------------------------------------------------------------------------- +# Achievement posting logic +# --------------------------------------------------------------------------- + + +@dataclass +class AwardedUserInfo: + """Container for user info needed to post achievements.""" + + user_id: int + slack_user_id: str + user_name: str + + +@dataclass +class RegionAchievementGroup: + """Groups achievement candidates by region for posting.""" + + team_id: str + slack_settings: SlackSettings + bot_token: str + # Map of achievement_id -> list of (AwardedUserInfo, CandidateAward) + achievements: Dict[int, List[Tuple[AwardedUserInfo, CandidateAward]]] + + +def _get_ssl_context(): + """Create SSL context for Slack client.""" + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + return ssl_context + + +def _build_achievement_message(achievement: Achievement, user_tag: str) -> str: + """Build a message for a single achievement award.""" + msg = f"🏆 *{achievement.name}*" + if achievement.description: + msg += f"\n_{achievement.description}_" + msg += f"\n\nEarned by {user_tag}!" + if achievement.image_url: + msg += f"\n{achievement.image_url}" + return msg + + +def _build_achievement_blocks(achievement: Achievement, user_tag: str) -> List[Dict]: + """Build Slack blocks for a single achievement award.""" + blocks = [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"🏆 *{achievement.name}*\nEarned by {user_tag}!"}, + } + ] + if achievement.description: + blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": f"_{achievement.description}_"}]}) + if achievement.image_url: + blocks.append({"type": "image", "image_url": achievement.image_url, "alt_text": achievement.name}) + return blocks + + +def _build_summary_message(achievement: Achievement, user_awards: List[Tuple[AwardedUserInfo, CandidateAward]]) -> str: + """Build a summary line for an achievement with multiple earners.""" + user_mentions = [] + user_counts: Dict[str, int] = defaultdict(int) + + for user_info, _candidate in user_awards: + user_counts[user_info.slack_user_id] += 1 + + for slack_user_id, count in user_counts.items(): + mention = f"<@{slack_user_id}>" + if count > 1: + mention += f" (x{count})" + user_mentions.append(mention) + + earners = ", ".join(user_mentions) + desc = f": {achievement.description}" if achievement.description else "" + return f"🏆 *{achievement.name}*{desc}\nEarned by {earners}" + + +def _build_dm_message(achievement: Achievement) -> str: + """Build a DM message for an individual achievement notification.""" + msg = f"🎉 Congratulations! You've earned an achievement!\n\n🏆 *{achievement.name}*" + if achievement.description: + msg += f"\n_{achievement.description}_" + if achievement.image_url: + msg += f"\n{achievement.image_url}" + return msg + + +def _build_dm_blocks(achievement: Achievement) -> List[Dict]: + """Build Slack blocks for an achievement DM.""" + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"🎉 Congratulations! You've earned an achievement!\n\n🏆 *{achievement.name}*", + }, + } + ] + if achievement.description: + blocks.append({"type": "context", "elements": [{"type": "mrkdwn", "text": f"_{achievement.description}_"}]}) + if achievement.image_url: + blocks.append({"type": "image", "image_url": achievement.image_url, "alt_text": achievement.name}) + return blocks + + +def post_achievements(session: Session, candidates: Sequence[CandidateAward], dry_run: bool) -> None: + """Post achievement notifications based on each region's settings. + + Groups candidates by their home region's Slack space and posts according + to the region's achievement_send_option setting: + - post_individually: Post each achievement separately in achievement_channel + - post_summary: Post a daily summary grouped by achievement in achievement_channel + - send_in_dms_only: Send DM to each user for their achievements + """ + if not candidates: + return + + # Gather all unique user_ids and achievement_ids + user_ids = list({c.user_id for c in candidates}) + achievement_ids = list({c.achievement_id for c in candidates}) + + # Load users with their home regions + users_query = select(User).filter(User.id.in_(user_ids)) + users_by_id: Dict[int, User] = {u.id: u for u in session.scalars(users_query).all()} + + # Load achievements + achievements_query = select(Achievement).filter(Achievement.id.in_(achievement_ids)) + achievements_by_id: Dict[int, Achievement] = {a.id: a for a in session.scalars(achievements_query).all()} + + # Find unique home_region_ids (filter out None) + home_region_ids = list({u.home_region_id for u in users_by_id.values() if u.home_region_id}) + if not home_region_ids: + print("No users with home regions found. Skipping achievement posting.") + return + + # Load SlackSpaces for home regions via Org_x_SlackSpace + slack_space_query = ( + select(Org_x_SlackSpace.org_id, SlackSpace) + .join(SlackSpace, SlackSpace.id == Org_x_SlackSpace.slack_space_id) + .filter(Org_x_SlackSpace.org_id.in_(home_region_ids)) + ) + region_slack_spaces: Dict[int, SlackSpace] = {} + for org_id, slack_space in session.execute(slack_space_query).all(): + region_slack_spaces[org_id] = slack_space + + if not region_slack_spaces: + print("No Slack spaces found for user home regions. Skipping achievement posting.") + return + + # Get all team_ids we need slack users for + team_ids = list({ss.team_id for ss in region_slack_spaces.values()}) + + # Load SlackUsers for all our users across all relevant teams + slack_users_query = select(SlackUser).filter(SlackUser.user_id.in_(user_ids), SlackUser.slack_team_id.in_(team_ids)) + # Map: (user_id, team_id) -> SlackUser + slack_users_map: Dict[Tuple[int, str], SlackUser] = {} + for su in session.scalars(slack_users_query).all(): + slack_users_map[(su.user_id, su.slack_team_id)] = su + + # Group candidates by region (team_id) + region_groups: Dict[str, RegionAchievementGroup] = {} + + for candidate in candidates: + user = users_by_id.get(candidate.user_id) + if not user or not user.home_region_id: + continue + + slack_space = region_slack_spaces.get(user.home_region_id) + if not slack_space: + continue + + team_id = slack_space.team_id + slack_user = slack_users_map.get((user.id, team_id)) + if not slack_user: + continue + + # Initialize group if needed + if team_id not in region_groups: + settings = SlackSettings(**slack_space.settings) if slack_space.settings else SlackSettings(team_id=team_id) + region_groups[team_id] = RegionAchievementGroup( + team_id=team_id, + slack_settings=settings, + bot_token=slack_space.bot_token, + achievements={}, + ) + + group = region_groups[team_id] + user_info = AwardedUserInfo( + user_id=user.id, + slack_user_id=slack_user.slack_id, + user_name=slack_user.user_name or user.f3_name or "Unknown", + ) + + if candidate.achievement_id not in group.achievements: + group.achievements[candidate.achievement_id] = [] + group.achievements[candidate.achievement_id].append((user_info, candidate)) + + # Post for each region based on settings + for team_id, group in region_groups.items(): + settings = group.slack_settings + + # Check if achievements are enabled + if not settings.send_achievements: + print(f"[{team_id}] Achievement posting disabled. Skipping.") + continue + + send_option = settings.achievement_send_option or "post_summary" + achievement_channel = settings.achievement_channel + + if not group.bot_token: + print(f"[{team_id}] No bot token available. Skipping.") + continue + + if dry_run: + print(f"[DRY-RUN] [{team_id}] Would post achievements with option: {send_option}") + for ach_id, user_awards in group.achievements.items(): + ach = achievements_by_id.get(ach_id) + ach_name = ach.name if ach else f"Achievement #{ach_id}" + users = [ui.user_name for ui, _ in user_awards] + print(f" - {ach_name}: {', '.join(users)}") + continue + + ssl_context = _get_ssl_context() + client = WebClient(group.bot_token, ssl=ssl_context) + + if send_option == "post_individually": + _post_individually(client, achievement_channel, group, achievements_by_id, team_id) + elif send_option == "post_summary": + _post_summary(client, achievement_channel, group, achievements_by_id, team_id) + elif send_option == "send_in_dms_only": + _send_dms(client, group, achievements_by_id, team_id) + else: + print(f"[{team_id}] Unknown send_option: {send_option}. Defaulting to summary.") + _post_summary(client, achievement_channel, group, achievements_by_id, team_id) + + +def _post_individually( + client: WebClient, + channel: str, + group: RegionAchievementGroup, + achievements_by_id: Dict[int, Achievement], + team_id: str, +) -> None: + """Post each achievement as an individual message.""" + if not channel: + print(f"[{team_id}] No achievement channel configured. Skipping individual posts.") + return + + for ach_id, user_awards in group.achievements.items(): + achievement = achievements_by_id.get(ach_id) + if not achievement: + continue + + for user_info, _candidate in user_awards: + user_tag = f"<@{user_info.slack_user_id}>" + msg = _build_achievement_message(achievement, user_tag) + blocks = _build_achievement_blocks(achievement, user_tag) + + try: + client.chat_postMessage(channel=channel, text=msg, blocks=blocks) + print(f"[{team_id}] Posted achievement '{achievement.name}' for {user_info.user_name}") + except Exception as e: + print(f"[{team_id}] Error posting achievement: {e}") + + +def _post_summary( + client: WebClient, + channel: str, + group: RegionAchievementGroup, + achievements_by_id: Dict[int, Achievement], + team_id: str, +) -> None: + """Post a single summary of all achievements earned.""" + if not channel: + print(f"[{team_id}] No achievement channel configured. Skipping summary post.") + return + + # Build sections with their corresponding achievements for image support + section_data: List[Tuple[Achievement, str]] = [] + for ach_id, user_awards in group.achievements.items(): + achievement = achievements_by_id.get(ach_id) + if not achievement: + continue + + section_text = _build_summary_message(achievement, user_awards) + section_data.append((achievement, section_text)) + + if not section_data: + return + + today_str = datetime.now(UTC).strftime("%B %d, %Y") + header = f"📊 *Achievement Summary for {today_str}*" + full_msg = header + "\n\n" + "\n\n".join([text for _, text in section_data]) + + blocks = [ + {"type": "header", "text": {"type": "plain_text", "text": f"📊 Achievement Summary for {today_str}"}}, + {"type": "divider"}, + ] + for achievement, section_text in section_data: + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": section_text}}) + if achievement.image_url: + blocks.append({"type": "image", "image_url": achievement.image_url, "alt_text": achievement.name}) + + try: + client.chat_postMessage(channel=channel, text=full_msg, blocks=blocks) + print(f"[{team_id}] Posted achievement summary with {len(section_data)} achievement types") + except Exception as e: + print(f"[{team_id}] Error posting achievement summary: {e}") + + +def _send_dms( + client: WebClient, + group: RegionAchievementGroup, + achievements_by_id: Dict[int, Achievement], + team_id: str, +) -> None: + """Send DMs to each user for their achievements.""" + # Group by user to batch their achievements + user_achievements: Dict[str, List[Tuple[Achievement, CandidateAward]]] = defaultdict(list) + + for ach_id, user_awards in group.achievements.items(): + achievement = achievements_by_id.get(ach_id) + if not achievement: + continue + + for user_info, _candidate in user_awards: + user_achievements[user_info.slack_user_id].append((achievement, _candidate)) + + for slack_user_id, achievements in user_achievements.items(): + for achievement, _candidate in achievements: + msg = _build_dm_message(achievement) + blocks = _build_dm_blocks(achievement) + + try: + client.chat_postMessage(channel=slack_user_id, text=msg, blocks=blocks) + print(f"[{team_id}] Sent DM for achievement '{achievement.name}' to {slack_user_id}") + except Exception as e: + print(f"[{team_id}] Error sending DM to {slack_user_id}: {e}") + + +def main(): # pragma: no cover - CLI + parser = argparse.ArgumentParser(description="Auto-award achievements") + parser.add_argument("--achievement-id", type=int, help="Process only a single achievement id", default=None) + parser.add_argument("--dry-run", action="store_true", help="Do not persist, only log actions") + parser.add_argument("--skip-post", action="store_true", help="Skip posting achievements to Slack") + parser.add_argument( + "--today", type=str, help="Override today's date (YYYY-MM-DD, UTC) for backfilling / testing", default=None + ) + args = parser.parse_args() + print(f"Auto-award achievements started at {datetime.now(UTC).isoformat()}") + print(f"Arguments: achievement_id={args.achievement_id}, dry_run={args.dry_run}, today={args.today}") + + today = datetime.strptime(args.today, "%Y-%m-%d").date() if args.today else datetime.now(UTC).date() + current_hour = datetime.now(pytz.timezone("US/Central")).hour + + if current_hour != constants.ACHIEVEMENT_AWARD_HOUR_CST: + return + + with get_session() as session: + ach_query = select(Achievement).filter(Achievement.auto_award.is_(True), Achievement.is_active.is_(True)) + if args.achievement_id: + ach_query = ach_query.filter(Achievement.id == args.achievement_id) + achievements = session.scalars(ach_query).all() + print(f"Processing {len(achievements)} achievements...") + total_candidates = 0 + all_candidates = [] + for ach in achievements: + cands = process_achievement(session, ach, today) + award_candidates(session, cands, args.dry_run) + total_candidates += len(cands) + all_candidates.extend(cands) + + # Post achievement notifications + if all_candidates and not args.skip_post: + print(f"\nPosting {len(all_candidates)} achievement notifications...") + post_achievements(session, all_candidates, args.dry_run) + + # save a csv of candidates for record-keeping + if all_candidates and args.dry_run: + with open(f"achievement_candidates_{today}.csv", "w") as f: + f.write("achievement_id,user_id,award_year,award_period,metric\n") + for c in all_candidates: + f.write(f"{c.achievement_id},{c.user_id},{c.award_year},{c.award_period},{c.metric}\n") + print(f"Done. Candidates processed: {total_candidates}") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/apps/slackbot/scripts/backblast_reminders.py b/apps/slackbot/scripts/backblast_reminders.py new file mode 100644 index 00000000..ddf9d68c --- /dev/null +++ b/apps/slackbot/scripts/backblast_reminders.py @@ -0,0 +1,226 @@ +import os +import ssl +import sys +from datetime import datetime, timedelta +from logging import Logger + +import pytz +from f3_data_models.utils import DbManager + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +from dataclasses import dataclass, field +from typing import List + +from f3_data_models.models import ( + Attendance, + Attendance_x_AttendanceType, + EventInstance, + EventType, + EventType_x_EventInstance, + Org, + Org_x_SlackSpace, + Series_Exception, + SlackSpace, + SlackUser, + User, +) +from f3_data_models.utils import get_session +from slack_sdk.web import WebClient +from sqlalchemy import and_, func, select +from sqlalchemy.orm import aliased + +from utilities.database.orm import SlackSettings +from utilities.helper_functions import current_date_cst, safe_get +from utilities.slack import actions, orm + +MSG_TEMPLATE = "Hey there, {q_name}! I hope that the {event_name} on {event_date} at {event_ao} went well! I have not seen a backblast posted for this event yet... Please click the button below to fill out the backblast so we can track those stats!" # noqa + + +@dataclass +class BackblastItem: + event: EventInstance + event_type: EventType + org: Org + parent_org: Org + q_name: str + slack_user_id: str + slack_settings: SlackSettings + + +@dataclass +class BackblastList: + items: List[BackblastItem] = field(default_factory=list) + + def pull_data(self): + session = get_session() + ParentOrg = aliased(Org) + + firstq_subquery = ( + select( + Attendance.event_instance_id, + Attendance.user_id, + func.row_number() + .over(partition_by=Attendance.event_instance_id, order_by=Attendance.created) + .label("rn"), + ) + .select_from(Attendance) + .join(Attendance_x_AttendanceType, Attendance.id == Attendance_x_AttendanceType.attendance_id) + .filter(Attendance_x_AttendanceType.attendance_type_id == 2) + .alias() + ) + + query = ( + session.query( + EventInstance, + EventType, + Org, + ParentOrg, + User.f3_name.label("q_name"), + SlackUser.slack_id, + SlackSpace.settings, + ) + .select_from(EventInstance) + .join(Org, Org.id == EventInstance.org_id) + .join(EventType_x_EventInstance, EventType_x_EventInstance.event_instance_id == EventInstance.id) + .join(EventType, EventType.id == EventType_x_EventInstance.event_type_id) + .join(ParentOrg, Org.parent_id == ParentOrg.id) + .join(Org_x_SlackSpace, Org_x_SlackSpace.org_id == ParentOrg.id) + .join(SlackSpace, Org_x_SlackSpace.slack_space_id == SlackSpace.id) + .join( + firstq_subquery, + and_( + EventInstance.id == firstq_subquery.c.event_instance_id, + firstq_subquery.c.rn == 1, + ), + ) + .outerjoin(User, User.id == firstq_subquery.c.user_id) + .outerjoin(SlackUser, and_(User.id == SlackUser.user_id, SlackUser.slack_team_id == SlackSpace.team_id)) + .filter( + EventInstance.start_date < current_date_cst(), # + timedelta(days=1), # eventually configurable + EventInstance.start_date >= (current_date_cst() - timedelta(days=5)), # eventually configurable + EventInstance.backblast_ts.is_(None), # not already sent + EventInstance.is_active, # not canceled + ) + .order_by(ParentOrg.name, Org.name, EventInstance.start_time) + ) + records = query.all() + session.expunge_all() + self.items = [ + BackblastItem( + event=r[0], + event_type=r[1], + org=r[2], + parent_org=r[3], + q_name=r[4], + slack_user_id=r[5], + slack_settings=SlackSettings(**r[6]), + ) + for r in records + ] + session.close() + + +def send_backblast_reminders(force=False): + # get the current time in US/Central timezone + current_time = datetime.now(pytz.timezone("US/Central")) + # check if the current time is between 5:00 PM and 6:00 PM, eventually configurable + if current_time.hour == 17 or force: + backblast_list = BackblastList() + backblast_list.pull_data() + + for backblast in backblast_list.items: + # TODO: add some handling for missing stuff + msg = MSG_TEMPLATE.format( + q_name=backblast.q_name, + event_name=backblast.event_type.name, + event_date=backblast.event.start_date.strftime("%m/%d"), + event_ao=backblast.org.name, + ) + + slack_bot_token = backblast.slack_settings.bot_token + backblast_reminder_days = backblast.slack_settings.backblast_reminder_days + if backblast_reminder_days is None: + backblast_reminder_days = 5 + + if ( + slack_bot_token + and backblast.slack_user_id + and not safe_get(backblast.event.meta, "backblast_reminder_dismissed") + and backblast.event.series_exception != Series_Exception.closed + ): + if ( + backblast_reminder_days > 0 + and (current_date_cst() - backblast.event.start_date).days <= backblast_reminder_days + ): + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + slack_client = WebClient(slack_bot_token, ssl=ssl_context) + blocks = [ + orm.SectionBlock(label=msg), + orm.ActionsBlock( + elements=[ + orm.ButtonElement( + label="Fill Out Backblast", + value=str(backblast.event.id), + style="primary", + action=actions.MSG_EVENT_BACKBLAST_BUTTON, + ), + orm.ButtonElement( + label="Already Posted", + value=str(backblast.event.id), + action=actions.MSG_EVENT_BACKBLAST_ALREADY_BUTTON, + ), + ] + ), + ] + blocks = [b.as_form_field() for b in blocks] + try: + slack_client.chat_postMessage(channel=backblast.slack_user_id, text=msg, blocks=blocks) + except Exception as e: + print(f"Error sending backblast reminder to {backblast.slack_user_id}: {e}") + continue + + +def handle_backblast_reminder_dismiss( + body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings +): + event_id = safe_get(body, "actions", 0, "value") + if not event_id: + logger.error("No event ID found in the action payload.") + return + + try: + event_instance = DbManager.get(EventInstance, event_id) + except Exception as e: + logger.error(f"Error fetching event with ID {event_id}: {e}") + return + + meta = event_instance.meta or {} + meta["backblast_reminder_dismissed"] = True + DbManager.update_record(EventInstance, event_id, {EventInstance.meta: meta}) + + # update the message to reflect dismissal + try: + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + text="Backblast reminder dismissed.", + blocks=[ + orm.SectionBlock( + label="Backblast reminder dismissed. To avoid confusion in the future, select your Q from the backblast dropdown vs. using the 'unscheduled event' option." # noqa + ).as_form_field(), + orm.ImageBlock( + image_url="https://storage.googleapis.com/backblast-images/BackblastSelectExample.png", + alt_text="Select Q Example", + ).as_form_field(), + ], + ) + except Exception as e: + logger.error(f"Error updating message for event ID {event_id}: {e}") + return + + +if __name__ == "__main__": + send_backblast_reminders(force=True) diff --git a/apps/slackbot/scripts/calendar_images.py b/apps/slackbot/scripts/calendar_images.py new file mode 100644 index 00000000..59f25675 --- /dev/null +++ b/apps/slackbot/scripts/calendar_images.py @@ -0,0 +1,589 @@ +import os +import sys +from typing import List + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +import random +import shutil +from datetime import datetime, timedelta + +import pytz +from f3_data_models.models import ( + Attendance, + Attendance_x_AttendanceType, + EventInstance, + EventTag, + EventTag_x_EventInstance, + EventType, + EventType_x_EventInstance, + Location, + Org, + Org_Type, + Org_x_SlackSpace, + Series_Exception, + SlackSpace, + User, +) + +# import dataframe_image as dfi +from f3_data_models.utils import DbManager, get_session +from slack_sdk import WebClient +from slack_sdk.models import blocks +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import aliased + +from utilities.constants import EVENT_TAG_COLORS, GCP_IMAGE_URL, LOCAL_DEVELOPMENT, S3_IMAGE_URL +from utilities.helper_functions import current_date_cst, safe_get, update_local_region_records +from utilities.slack import actions + +DB_SCHEMA = os.getenv("DATABASE_SCHEMA", "f3_staging") + + +def time_int_to_str(time: int) -> str: + return f"{time // 100:02d}{time % 100:02d}" + + +def highlight_cells(s, color_dicts): + import pandas as pd + + highlight_cells_list = [] + for cell in s: + cell_str = str(cell) + tags = cell_str.split("\n") + found = False + if tags: + for tag in tags: + if tag in color_dicts["region"].keys(): + highlight_cells_list.append(f"background-color: {EVENT_TAG_COLORS[color_dicts['region'][tag]][0]}") + found = True + break + elif tag in color_dicts["nation_not_black"].keys(): + highlight_cells_list.append( + f"background-color: {EVENT_TAG_COLORS[color_dicts['nation_not_black'][tag]][0]}" + ) + found = True + break + elif tag in color_dicts["nation_black"].keys(): + highlight_cells_list.append( + f"background-color: {EVENT_TAG_COLORS[color_dicts['nation_black'][tag]][0]}" + ) + found = True + break + elif tag in color_dicts["generic"].keys(): + highlight_cells_list.append(f"background-color: {EVENT_TAG_COLORS[color_dicts['generic'][tag]][0]}") + found = True + break + if not found: + highlight_cells_list.append("background-color: #000000") + return pd.Series(highlight_cells_list) + + +def set_text_color(s, color_dicts): + text_color_list = [] + for cell in s: + cell_str = str(cell) + tags = cell_str.split("\n") + found = False + if tags: + for tag in tags: + if tag in color_dicts["region"].keys(): + text_color_list.append(f"color: {EVENT_TAG_COLORS[color_dicts['region'][tag]][1]}") + found = True + break + elif tag in color_dicts["nation_not_black"].keys(): + text_color_list.append(f"color: {EVENT_TAG_COLORS[color_dicts['nation_not_black'][tag]][1]}") + found = True + break + elif tag in color_dicts["nation_black"].keys(): + text_color_list.append(f"color: {EVENT_TAG_COLORS[color_dicts['nation_black'][tag]][1]}") + found = True + break + elif tag in color_dicts["generic"].keys(): + text_color_list.append(f"color: {EVENT_TAG_COLORS[color_dicts['generic'][tag]][1]}") + found = True + break + if not found: + text_color_list.append("color: #F0FFFF") + return text_color_list + + +def generate_calendar_images(force: bool = False): + import dataframe_image as dfi + import pandas as pd + + with get_session() as session: + tomorrow_day_of_week = (current_date_cst() + timedelta(days=1)).weekday() + current_week_start = current_date_cst() + timedelta(days=-tomorrow_day_of_week + 1) + current_week_end = current_date_cst() + timedelta(days=7 - tomorrow_day_of_week + 1) + next_week_start = current_week_start + timedelta(weeks=1) + next_week_end = current_week_end + timedelta(weeks=1) + + firstq_subquery = ( + select( + Attendance.event_instance_id, + Attendance.user_id, + func.row_number() + .over(partition_by=Attendance.event_instance_id, order_by=Attendance.created) + .label("rn"), + ) + .select_from(Attendance) + .join(Attendance_x_AttendanceType, Attendance.id == Attendance_x_AttendanceType.attendance_id) + .join(EventInstance, EventInstance.id == Attendance.event_instance_id) + .filter( + Attendance_x_AttendanceType.attendance_type_id == 2, + EventInstance.start_date >= current_week_start, + EventInstance.start_date < next_week_end, + ) + .alias() + ) + + attendance_subquery = ( + select( + Attendance.event_instance_id, + func.max(Attendance.updated).label("q_last_updated"), + ) + .select_from(Attendance) + .join(Attendance_x_AttendanceType, Attendance.id == Attendance_x_AttendanceType.attendance_id) + .join(EventInstance, EventInstance.id == Attendance.event_instance_id) + .filter( + Attendance_x_AttendanceType.attendance_type_id == 2, + EventInstance.start_date >= current_week_start, + EventInstance.start_date < next_week_end, + ) + .group_by(Attendance.event_instance_id) + .alias() + ) + + RegionOrg = aliased(Org) + + query = ( + session.query( + EventInstance.start_date, + EventInstance.start_time, + EventInstance.updated.label("event_updated"), + EventInstance.pax_count, + EventInstance.series_exception, + EventTag.name.label("event_tag"), + EventTag.color.label("event_tag_color"), + EventType.name.label("event_type"), + EventType.acronym.label("event_acronym"), + Org.name.label("ao_name"), + Org.description.label("ao_description"), + Org.parent_id.label("ao_parent_id"), + User.f3_name.label("q_name"), + Location.name.label("location_name"), + Location.description.label("location_description"), + Location.address_street.label("location_address_street"), + attendance_subquery.c.q_last_updated, + RegionOrg.name.label("region_name"), + RegionOrg.id.label("region_id"), + ) + .select_from(EventInstance) + .outerjoin(EventTag_x_EventInstance, EventInstance.id == EventTag_x_EventInstance.event_instance_id) + .outerjoin(EventTag, EventTag_x_EventInstance.event_tag_id == EventTag.id) + .join(EventType_x_EventInstance, EventInstance.id == EventType_x_EventInstance.event_instance_id) + .join(EventType, EventType_x_EventInstance.event_type_id == EventType.id) + .join(Org, EventInstance.org_id == Org.id) + .join(RegionOrg, RegionOrg.id == Org.parent_id) + .outerjoin(Location, EventInstance.location_id == Location.id) + .outerjoin( + firstq_subquery, + and_(EventInstance.id == firstq_subquery.c.event_instance_id, firstq_subquery.c.rn == 1), + ) + .outerjoin(User, User.id == firstq_subquery.c.user_id) + .outerjoin(attendance_subquery, EventInstance.id == attendance_subquery.c.event_instance_id) + .filter( + (EventInstance.start_date >= current_week_start), + (EventInstance.start_date < next_week_end), + (EventInstance.is_active), + # (EventInstance.series_id.is_not(None)), + or_(EventTag.name.is_(None), EventTag.name != "Off-The-Books"), + ) + ) + + results = query.all() + df_all = pd.DataFrame(results) + + event_tags = session.query(EventTag).all() + + region_org_records = ( + session.query(Org, Org_x_SlackSpace, SlackSpace) + .select_from(Org) + .join(Org_x_SlackSpace, Org.id == Org_x_SlackSpace.org_id) + .join(SlackSpace, Org_x_SlackSpace.slack_space_id == SlackSpace.id) + .filter(Org.org_type == Org_Type.region) + .all() + ) + + for region_id in df_all["region_id"].unique(): + try: + region_id = int(region_id) + df_full = df_all[df_all["region_id"] == region_id].copy() + region_name = df_full["region_name"].iloc[0] + region_org_record = safe_get([r for r in region_org_records if r[0].id == region_id], 0) + if region_org_record: + slack_app_settings: dict = region_org_record[2].settings + print(f"Running for {region_name}") + + group_by_option = slack_app_settings.get("calendar_group_by_option") or "ao" + color_dict_region = {t.name: t.color for t in event_tags if t.specific_org_id == region_id} + color_dict_nation_not_black = { + t.name: t.color for t in event_tags if t.specific_org_id is None and t.color != "Black" + } + color_dict_nation_black = { + t.name: t.color for t in event_tags if t.specific_org_id is None and t.color == "Black" + } + color_dict_generic = { + "OPEN!": slack_app_settings.get("open_event_color") or "Green", + "CLOSED": "Closed", + } + all_color_dicts = { + "region": color_dict_region, + "nation_not_black": color_dict_nation_not_black, + "nation_black": color_dict_nation_black, + "generic": color_dict_generic, + } + calendar_updated = False + + for week in ["current", "next"]: + if week == "current": + df = df_full[ + (df_full["start_date"] >= current_week_start) + & (df_full["start_date"] < current_week_end) + ].copy() + else: + df = df_full[ + (df_full["start_date"] >= next_week_start) & (df_full["start_date"] < next_week_end) + ].copy() + + max_event_updated = ( + datetime(year=1900, month=1, day=1) + if df["event_updated"].isnull().all() + else df["event_updated"].max() + ) + max_q_last_updated = ( + datetime(year=1900, month=1, day=1) + if df["q_last_updated"].isnull().all() + else df["q_last_updated"].max() + ) + max_changed = max(max_event_updated, max_q_last_updated) + max_changed = datetime(year=1900, month=1, day=1) if pd.isnull(max_changed) else max_changed + now_cst = datetime.now(pytz.timezone("US/Central")) + first_sunday_run = now_cst.weekday() == 6 and now_cst.hour < 1 + + if ( + (max_changed > datetime.now() - timedelta(hours=1)) + or first_sunday_run + or LOCAL_DEVELOPMENT + or force + ): + # convert start_date from date to string + df.loc[:, "event_date"] = pd.to_datetime(df["start_date"]) + df.loc[:, "event_date_fmt"] = df["event_date"].dt.strftime("%Y/%m/%d") + df.loc[:, "event_time"] = df["start_time"] + df.loc[df["q_name"].isna(), "q_name"] = "OPEN!" + df.loc[:, "q_name"] = df["q_name"].str.replace(r"\s\(([\s\S]*?\))", "", regex=True) + + # if pax_count is not null then second line is pax_count otherwise event_acronym + event_time # noqa + df.loc[:, "label"] = df["q_name"] + "\n" + df["event_acronym"] + " " + df["event_time"] + df.loc[df["pax_count"].notna(), "label"] = ( + df["q_name"] + "\nPAX: " + df["pax_count"].astype(str).str.replace(".0", "") + ) + + df.loc[(df["event_tag"].notnull()), ("label")] = ( + df["q_name"] + "\n" + df["event_tag"] + "\n" + df["event_time"] + ) + df.loc[(df["pax_count"].notna()) & (df["event_tag"].notnull()), ("label")] = ( + df["q_name"] + + "\n" + + df["event_tag"] + + "\nPAX: " + + df["pax_count"].astype(str).str.replace(".0", "") + ) + + # Override label for closed events + df.loc[df["series_exception"] == Series_Exception.closed, "label"] = "CLOSED" + + if group_by_option == "ao": + df.loc[:, "AO\nLocation"] = df["ao_name"] # + "\n" + df["ao_description"] + df.loc[df["ao_description"].notnull(), "AO\nLocation"] = ( + df["ao_name"] + "\n" + df["ao_description"] + ) + row_key_col = "AO\nLocation" + value_col = "label" + sort_key_col = "ao_name" + else: + # Create a readable location label similar to `get_location_display_name`. + location_name = df["location_name"].fillna("") + location_description = df["location_description"].fillna("") + location_address_street = df["location_address_street"].fillna("") + + df.loc[:, "Location"] = location_name + desc_mask = (df["Location"] == "") & (location_description != "") + df.loc[desc_mask, "Location"] = location_description[desc_mask].str[:30] + street_mask = (df["Location"] == "") & (location_address_street != "") + df.loc[street_mask, "Location"] = location_address_street[street_mask].str[:30] + # Fallback to ao name if no location info is available + df.loc[df["Location"] == "", "Location"] = df["ao_name"] + + # Include AO name in the cell now that the row header is the location. + df.loc[:, "cell_label"] = df["ao_name"] + "\n" + df["label"] + row_key_col = "Location" + value_col = "cell_label" + sort_key_col = "Location" + + df.loc[:, "event_day_of_week"] = df["event_date"].dt.day_name() + df.to_csv(f"debug_{region_name}_{week}.csv", index=False) + + # Combine cells for days within the chosen grouping (AO vs location). + df.sort_values([sort_key_col, "event_date", "event_time"], ignore_index=True, inplace=True) + prior_date = "" + prior_label = "" + prior_group_key = "" + include_list = [] + for i in range(len(df)): + row2 = df.loc[i] + if (row2["event_date_fmt"] == prior_date) & (row2[sort_key_col] == prior_group_key): + df.loc[i, value_col] = prior_label + "\n" + df.loc[i, value_col] + prior_label = df.loc[i, value_col] + include_list.append(False) + else: + if prior_label != "": + include_list.append(True) + prior_date = row2["event_date_fmt"] + prior_group_key = row2[sort_key_col] + prior_label = row2[value_col] + + include_list.append(True) + + # filter out duplicate dates + df = df[include_list] + + # Reshape to wide format by date + df2 = df.pivot( + index=row_key_col, + columns=["event_day_of_week", "event_date_fmt"], + values=value_col, + ).fillna("") + + # Sort and enforce word wrap on labels + df2.sort_index(axis=1, level=["event_date_fmt"], inplace=True) + df2.columns = df2.columns.map("\n".join).str.strip("\n") + df2.reset_index(inplace=True) + + # Take out "The " for sorting + grouping_sort_col = f"{row_key_col}2" + df2[grouping_sort_col] = df2[row_key_col].str.replace("The ", "") + df2.sort_values(by=[grouping_sort_col], axis=0, inplace=True) + df2.drop([grouping_sort_col], axis=1, inplace=True) + df2.reset_index(inplace=True, drop=True) + + # Add timestamp footer row + now_cst = datetime.now(pytz.timezone("US/Central")) + timestamp_str = f"Last updated at {now_cst.strftime('%m/%d %I:%M %p')} CST" + footer_row = dict.fromkeys(df2.columns, "") + footer_row[row_key_col] = timestamp_str + df2 = pd.concat([df2, pd.DataFrame([footer_row])], ignore_index=True) + + # Set CSS properties for th elements in dataframe + th_props = [ + ("font-size", "15px"), + ("text-align", "center"), + ("font-weight", "bold"), + ("color", "#F0FFFF"), + ("background-color", "#000000"), + ("white-space", "pre-wrap"), + ("border", "1px solid #F0FFFF"), + ] + + # Set CSS properties for td elements in dataframe + td_props = [ + ("font-size", "15px"), + ("text-align", "center"), + ("white-space", "pre-wrap"), + # ('background-color', '#000000'), + # ("color", "#F0FFFF"), + ("border", "1px solid #F0FFFF"), + ] + + # Set table styles + styles = [ + {"selector": "th", "props": th_props}, + {"selector": "td", "props": td_props}, + ] + + # set style and export png + # df_styled = df2.style.set_table_styles(styles).apply(highlight_cells).hide_index() + # apply styles, hide the index + df_styled = ( + df2.style.set_table_styles(styles) + .apply(highlight_cells, color_dicts=all_color_dicts) + .hide(axis="index") + ) + df_styled = df_styled.apply(set_text_color, color_dicts=all_color_dicts, axis=1) + + # create calendar image + random_chars = "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=10)) + filename = f"{region_id}-{week}-{random_chars}.png" + filename_static = f"{region_id}-{week}.png" + if LOCAL_DEVELOPMENT: + dfi.export(df_styled, filename, table_conversion="playwright") + else: + dfi.export(df_styled, f"/mnt/calendar-images/{filename}", table_conversion="playwright") + if DB_SCHEMA == "f3_prod": + shutil.copyfile( + f"/mnt/calendar-images/{filename}", f"/mnt/calendar-images/{filename_static}" + ) + + # upload to s3 and remove local file + slack_app_settings = region_org_record[2].settings + existing_file = slack_app_settings.get(f"calendar_image_{week}") + + if LOCAL_DEVELOPMENT: + print( + f"Local development - skipping upload to S3 and deletion of old file for {filename}" + ) + else: + if existing_file: + try: + os.remove(f"/mnt/calendar-images/{existing_file}") + except Exception as e: + print(f"Error deleting old file {existing_file} from local storage: {e}") + slack_app_settings[f"calendar_image_{week}"] = filename + calendar_updated = True + + # post to slack channel if enabled + if ( + slack_app_settings.get("q_image_posting_enabled") + and slack_app_settings.get("q_image_posting_channel") + and slack_app_settings.get("bot_token") + and calendar_updated + ): + print("Posting to Slack channel") + client = WebClient(token=slack_app_settings["bot_token"]) + if LOCAL_DEVELOPMENT: + IMAGE_URL = S3_IMAGE_URL + else: + IMAGE_URL = GCP_IMAGE_URL + block_list = [blocks.HeaderBlock(text=":calendar: Q Calendar")] + if slack_app_settings.get("calendar_image_current"): + block_list.append( + blocks.ImageBlock( + image_url=IMAGE_URL.format( + bucket="f3nation-calendar-images", + image_name=slack_app_settings["calendar_image_current"], + ), + alt_text="This Week's Q Sheet", + ) + ) + if slack_app_settings.get("calendar_image_next"): + block_list.append( + blocks.ImageBlock( + image_url=IMAGE_URL.format( + bucket="f3nation-calendar-images", + image_name=slack_app_settings["calendar_image_next"], + ), + alt_text="Next Week's Q Sheet", + ) + ) + block_list.append( + blocks.ActionsBlock( + elements=[ + blocks.ButtonElement( + text=":calendar: Open Full Calendar", + action_id=actions.OPEN_CALENDAR_BUTTON, + ), + blocks.ButtonElement( + text=":world_map: Nearby Special Events", + action_id=actions.NEARBY_EVENTS_OPEN, + ), + ] + ) + ) + block_list.extend(create_special_events_blocks(slack_app_settings)) + try: + if slack_app_settings.get("q_image_posting_ts") and (not first_sunday_run): + try: + client.chat_update( + channel=slack_app_settings["q_image_posting_channel"], + ts=slack_app_settings["q_image_posting_ts"], + blocks=block_list, + text="Q Sheet", + ) + except Exception as e: + print(f"Error updating Slack message, posting new message: {e}") + response = client.chat_postMessage( + channel=slack_app_settings["q_image_posting_channel"], + text="Q Sheet", + blocks=block_list, + ) + if response["ok"]: + slack_app_settings["q_image_posting_ts"] = response["ts"] + else: + response = client.chat_postMessage( + channel=slack_app_settings["q_image_posting_channel"], + text="Q Sheet", + blocks=block_list, + ) + if response["ok"]: + slack_app_settings["q_image_posting_ts"] = response["ts"] + except Exception as e: + print(f"Error posting to Slack channel: {e}") + # update org record with new filename + print(f"Updating Slack app settings for region {region_name} with {slack_app_settings}") + session.query(SlackSpace).filter(SlackSpace.team_id == slack_app_settings["team_id"]).update( + {"settings": slack_app_settings} + ) + session.commit() + + except Exception as e: + print(f"Error processing region {region_id}: {e}") + update_local_region_records() + + +def create_special_events_text(events: List[EventInstance], slack_settings_dict: dict, max_events: int = 10) -> str: + text = "" + for i, event in enumerate(events[:max_events]): + text += f"{i + 1}. *{event.name}* - {event.start_date.strftime('%A, %B %d')} - {event.start_time} @ {event.org.name}\n" # noqa + + if event.preblast_ts: + # TODO: need to make this work for region-level events + if slack_settings_dict.get("default_preblast_destination") == "specified_channel": + channel_id = slack_settings_dict.get("preblast_destination_channel") + else: + channel_id = event.org.meta.get("slack_channel_id") + + if channel_id: + text += f"\n" # noqa + + return text + + +def create_special_events_blocks(slack_settings_dict: dict) -> blocks.Block: + blocks_list = [] + # list special events + special_events: List[EventInstance] = DbManager.find_records( + cls=EventInstance, + filters=[ + or_( + EventInstance.org_id == slack_settings_dict.get("org_id"), + EventInstance.org.has(Org.parent_id == slack_settings_dict.get("org_id")), + ), + EventInstance.start_date >= current_date_cst(), + EventInstance.is_active, + EventInstance.highlight, + ], + joinedloads=[EventInstance.org], + ) + # limit to 10 upcoming events + special_events = sorted(special_events, key=lambda x: (x.start_date, x.start_time))[:10] + if len(special_events) > 0: + blocks_list.append(blocks.HeaderBlock(text=":tada: Special Events:")) + msg = create_special_events_text(special_events, slack_settings_dict) + blocks_list.append(blocks.SectionBlock(text=blocks.MarkdownTextObject(text=msg))) + return blocks_list + + +if __name__ == "__main__": + generate_calendar_images(force=True) diff --git a/apps/slackbot/scripts/cloudbuild.yaml b/apps/slackbot/scripts/cloudbuild.yaml new file mode 100644 index 00000000..f9aa80d0 --- /dev/null +++ b/apps/slackbot/scripts/cloudbuild.yaml @@ -0,0 +1,39 @@ +steps: + - name: gcr.io/cloud-builders/gcloud + id: prepare-context + entrypoint: bash + args: + - -c + - | + set -euo pipefail + rm -rf scripts_build_ctx + mkdir -p scripts_build_ctx + # Copy scripts package + cp -a scripts scripts_build_ctx/ + # Copy shared code needed by scripts (add/remove as needed) + [ -d features ] && cp -a features scripts_build_ctx/ || true + [ -d utilities ] && cp -a utilities scripts_build_ctx/ || true + [ -d application ] && cp -a application scripts_build_ctx/ || true + [ -d infrastructure ] && cp -a infrastructure scripts_build_ctx/ || true + [ -d common ] && cp -a common scripts_build_ctx/ || true + # Clean Python caches + find scripts_build_ctx -type d -name __pycache__ -prune -exec rm -rf {} + + find scripts_build_ctx -type f -name '*.pyc' -delete + + - name: gcr.io/cloud-builders/docker + id: build-image + args: + - build + - -t + - $_IMAGE + - -f + - scripts/Dockerfile + - scripts_build_ctx + +images: + - $_IMAGE +substitutions: + _IMAGE: us-central1-docker.pkg.dev/PROJECT/REPO/f3-bot-scripts:$COMMIT_SHA + +options: + logging: CLOUD_LOGGING_ONLY diff --git a/apps/slackbot/scripts/home_region_nudge.py b/apps/slackbot/scripts/home_region_nudge.py new file mode 100644 index 00000000..49822a7d --- /dev/null +++ b/apps/slackbot/scripts/home_region_nudge.py @@ -0,0 +1,300 @@ +import argparse +import os +import sys +from collections import defaultdict +from datetime import datetime, timedelta +from logging import Logger +from typing import Optional + +import pytz +from f3_data_models.models import Attendance, EventInstance, Org, Org_x_SlackSpace, SlackSpace, SlackUser, User +from f3_data_models.utils import DbManager, get_session +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError +from sqlalchemy import case, func + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +from utilities.database.orm import SlackSettings +from utilities.helper_functions import safe_get +from utilities.slack import actions, orm + +# --- Configuration via environment variables --- +NUDGE_DAY = int(os.getenv("HOME_REGION_NUDGE_DAY", "21")) +NUDGE_HOUR = int(os.getenv("HOME_REGION_NUDGE_HOUR", "17")) +PCT_THRESHOLD_30 = float(os.getenv("HOME_REGION_NUDGE_PCT_30", "0.70")) +PCT_THRESHOLD_90 = float(os.getenv("HOME_REGION_NUDGE_PCT_90", "0.50")) +MIN_POSTS_30 = int(os.getenv("HOME_REGION_NUDGE_MIN_30", "4")) +MIN_POSTS_90 = int(os.getenv("HOME_REGION_NUDGE_MIN_90", "8")) + +USER_META_NUDGE_OPT_OUT = "home_region_nudge_opt_out" + +MSG_TEMPLATE = ( + "Hey {f3_name}! Your home region is set to *{home_region_name}*, but it looks like you've been " + "posting a lot in *{other_region_name}* lately. Would you like to switch your home region?" +) +MSG_SWITCHED = "Done! Your home region has been switched to *{new_region_name}*. Enjoy posting there!" +MSG_DISMISSED = "No problem! Your home region remains unchanged." +MSG_OPT_OUT = "Got it! We won't send you reminders about this again." + + +def _build_dm_blocks(f3_name: str, home_region_name: str, other_region_name: str, new_region_id: int) -> list: + msg = MSG_TEMPLATE.format( + f3_name=f3_name, + home_region_name=home_region_name, + other_region_name=other_region_name, + ) + blocks = [ + orm.SectionBlock(label=msg), + orm.ActionsBlock( + elements=[ + orm.ButtonElement( + label="Yes, switch!", + value=str(new_region_id), + style="primary", + action=actions.HOME_REGION_NUDGE_SWITCH_BUTTON, + ), + orm.ButtonElement( + label="No, keep current", + value="dismiss", + action=actions.HOME_REGION_NUDGE_DISMISS_BUTTON, + ), + orm.ButtonElement( + label="No, don't ask again", + value="opt_out", + action=actions.HOME_REGION_NUDGE_OPT_OUT_BUTTON, + ), + ] + ), + ] + return [b.as_form_field() for b in blocks] + + +def send_home_region_nudges(force: bool = False): + current_time = datetime.now(pytz.timezone("US/Central")) + if not force and (current_time.day != NUDGE_DAY or current_time.hour != NUDGE_HOUR): + return + + print("Pulling home region nudge activity data...") + cutoff_90 = (datetime.now() - timedelta(days=90)).date() + cutoff_30 = (datetime.now() - timedelta(days=30)).date() + + # Derive region_id: if the org is already a region use its id, otherwise use parent_id (AO -> region) + region_id_case = case( + (Org.org_type == "region", Org.id), + else_=Org.parent_id, + ) + + with get_session() as session: + rows = ( + session.query( + User.id.label("user_id"), + User.home_region_id.label("home_region_id"), + region_id_case.label("region_id"), + func.sum( + case( + (EventInstance.start_date >= cutoff_30, 1), + else_=0, + ) + ).label("posts_30"), + func.count(Attendance.id).label("posts_90"), + ) + .select_from(Attendance) + .join(User, User.id == Attendance.user_id) + .join(EventInstance, EventInstance.id == Attendance.event_instance_id) + .join(Org, Org.id == EventInstance.org_id) + .filter( + Attendance.is_planned == False, # noqa: E712 + EventInstance.start_date >= cutoff_90, + User.home_region_id.isnot(None), + ) + .group_by(User.id, User.home_region_id, region_id_case) + .all() + ) + + # Group rows by user_id + user_rows: dict[int, list] = defaultdict(list) + for row in rows: + if row.region_id is None: + continue + user_rows[row.user_id].append(row) + + # Build Slack client lookup: org_id -> team_id, team_id -> SlackSettings + slack_space_records: list[tuple[SlackSpace, Org_x_SlackSpace]] = DbManager.find_join_records2( + SlackSpace, + Org_x_SlackSpace, + filters=[True], + ) + org_to_team_id: dict[int, str] = {} + team_id_to_settings: dict[str, SlackSettings] = {} + for ss, oxss in slack_space_records: + settings = SlackSettings(**ss.settings) + org_to_team_id[oxss.org_id] = ss.team_id + team_id_to_settings[ss.team_id] = settings + + # Cache all region names upfront to avoid repeated DB calls + all_regions: list[Org] = DbManager.find_records(Org, filters=[Org.org_type == "region"]) + region_names: dict[int, str] = {r.id: r.name for r in all_regions} + + print(f"Found {len(user_rows)} users with recent activity. Checking qualification...") + sent_count = 0 + + for user_id, activity_rows in user_rows.items(): + home_region_id = activity_rows[0].home_region_id + total_90 = sum(r.posts_90 for r in activity_rows) + total_30 = sum(r.posts_30 for r in activity_rows) + + # Posts in regions other than the user's home region + other_rows = [r for r in activity_rows if r.region_id != home_region_id] + if not other_rows: + continue + + # Top non-home region by 90-day post count + top_other_region_id = max(other_rows, key=lambda r: r.posts_90).region_id + + other_90 = sum(r.posts_90 for r in other_rows if r.region_id == top_other_region_id) + other_30 = sum(r.posts_30 for r in other_rows if r.region_id == top_other_region_id) + + qualifies_30 = total_30 >= MIN_POSTS_30 and (other_30 / max(total_30, 1)) >= PCT_THRESHOLD_30 + qualifies_90 = ( + total_90 >= MIN_POSTS_90 + and (other_90 / max(total_90, 1)) >= PCT_THRESHOLD_90 + and (other_30 / max(total_30, 1)) >= 0.30 + ) # Ensure at least 30% in last 30 days to avoid nudging users who have recently switched + + if not qualifies_30 and not qualifies_90: + continue + + user_record = DbManager.get(User, user_id) + if not user_record: + continue + if safe_get(user_record.meta, USER_META_NUDGE_OPT_OUT): + continue + + team_id = org_to_team_id.get(home_region_id) + if not team_id: + print(f"No Slack workspace found for region {home_region_id}, skipping user {user_id}") + continue + + settings = team_id_to_settings.get(team_id) + if not settings or not settings.bot_token: + print(f"No bot token for team {team_id}, skipping user {user_id}") + continue + + slack_user = DbManager.find_first_record( + SlackUser, + filters=[SlackUser.user_id == user_id, SlackUser.slack_team_id == team_id], + ) + if not slack_user: + print(f"No Slack user found for user {user_id} in team {team_id}, skipping") + continue + + home_region_name = region_names.get(home_region_id, f"Region {home_region_id}") + other_region_name = region_names.get(top_other_region_id, f"Region {top_other_region_id}") + f3_name = user_record.f3_name or slack_user.user_name or "PAX" + + blocks = _build_dm_blocks( + f3_name=f3_name, + home_region_name=home_region_name, + other_region_name=other_region_name, + new_region_id=top_other_region_id, + ) + plain_text = ( + f"Hey {f3_name}! Your home region is set to {home_region_name}, but it looks like " + f"you post a lot in {other_region_name}. Would you like to switch your home region?" + ) + + client = WebClient(token=settings.bot_token) + try: + client.chat_postMessage( + channel=slack_user.slack_id, + text=plain_text, + blocks=blocks, + ) + sent_count += 1 + except SlackApiError as e: + print(f"Error sending DM to user {user_id} ({slack_user.slack_id}): {e.response['error']}") + + print(f"Home region nudge complete. Sent {sent_count} DMs.") + + +def _get_user_from_body(body: dict) -> tuple[Optional[User], Optional[SlackUser]]: + """Look up User and SlackUser records from a Slack action body.""" + slack_user_id = safe_get(body, "user", "id") + team_id = safe_get(body, "user", "team_id") or safe_get(body, "team", "id") + if not slack_user_id or not team_id: + return None, None + slack_user = DbManager.find_first_record( + SlackUser, + filters=[SlackUser.slack_id == slack_user_id, SlackUser.slack_team_id == team_id], + ) + if not slack_user: + return None, None + user_record = DbManager.get(User, slack_user.user_id) + return user_record, slack_user + + +def handle_home_region_switch(body: dict, client: WebClient, logger: Logger, context: dict, region_record): + """Handle the 'Yes, switch!' button — update the user's home_region_id.""" + new_region_id = int(body["actions"][0]["value"]) + user_record, _ = _get_user_from_body(body) + if not user_record: + logger.error("Could not find user for home region switch action") + return + + region = DbManager.get(Org, new_region_id) + region_name = region.name if region else f"Region {new_region_id}" + DbManager.update_record(User, user_record.id, {User.home_region_id: new_region_id}) + + msg = MSG_SWITCHED.format(new_region_name=region_name) + try: + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + text=msg, + blocks=[orm.SectionBlock(label=msg).as_form_field()], + ) + except Exception as e: + logger.error(f"Error updating DM after home region switch: {e}") + + +def handle_home_region_dismiss(body: dict, client: WebClient, logger: Logger, context: dict, region_record): + """Handle the 'No, keep current' button — update the DM and take no further action.""" + try: + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + text=MSG_DISMISSED, + blocks=[orm.SectionBlock(label=MSG_DISMISSED).as_form_field()], + ) + except Exception as e: + logger.error(f"Error updating DM after home region dismiss: {e}") + + +def handle_home_region_opt_out(body: dict, client: WebClient, logger: Logger, context: dict, region_record): + """Handle the 'No, don't ask again' button — set opt-out flag and update the DM.""" + user_record, _ = _get_user_from_body(body) + if not user_record: + logger.error("Could not find user for home region opt-out action") + return + + meta = user_record.meta or {} + meta[USER_META_NUDGE_OPT_OUT] = True + DbManager.update_record(User, user_record.id, {User.meta: meta}) + + try: + client.chat_update( + channel=body["channel"]["id"], + ts=body["message"]["ts"], + text=MSG_OPT_OUT, + blocks=[orm.SectionBlock(label=MSG_OPT_OUT).as_form_field()], + ) + except Exception as e: + logger.error(f"Error updating DM after home region opt-out: {e}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Send home region nudge DMs to qualifying users") + parser.add_argument("--force", action="store_true", help="Run regardless of day of month") + args = parser.parse_args() + send_home_region_nudges(force=args.force) diff --git a/apps/slackbot/scripts/hourly_runner.py b/apps/slackbot/scripts/hourly_runner.py new file mode 100644 index 00000000..32803cf4 --- /dev/null +++ b/apps/slackbot/scripts/hourly_runner.py @@ -0,0 +1,110 @@ +import os +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +import argparse + +import requests + +# from features import canvas +from scripts import ( + auto_preblast_send, + award_achievements, + backblast_reminders, + calendar_images, + home_region_nudge, + monthly_reporting, + preblast_reminders, + q_lineups, + update_slack_users, +) + +APP_URL = os.getenv("APP_URL", "http://localhost:8080") + + +def run_all_hourly_scripts(force: bool = False, run_reporting: bool = True, reporting_org_id: int | None = None): + print("Running hourly scripts") + + print("Running calendar images") + try: + calendar_images.generate_calendar_images(force=force) + except Exception as e: + print(f"Error generating calendar images: {e}") + + print("Running backblast reminders") + try: + backblast_reminders.send_backblast_reminders() + except Exception as e: + print(f"Error sending backblast reminders: {e}") + + print("Running preblast reminders") + try: + preblast_reminders.send_preblast_reminders() + except Exception as e: + print(f"Error sending preblast reminders: {e}") + + print("Running automated preblast send") + try: + auto_preblast_send.send_automated_preblasts() + except Exception as e: + print(f"Error sending automated preblasts: {e}") + + print("Running Q lineups") + try: + q_lineups.send_lineups(force=force) + except Exception as e: + print(f"Error sending Q lineups: {e}") + + print("Updating Slack users") + try: + update_slack_users.update_slack_users() + except Exception as e: + print(f"Error updating Slack users: {e}") + + print("Updating home regions for users") + try: + update_slack_users.update_home_regions() + except Exception as e: + print(f"Error updating home regions for users: {e}") + + print("Running home region nudge") + try: + home_region_nudge.send_home_region_nudges() + except Exception as e: + print(f"Error running home region nudge: {e}") + + if run_reporting: + print("Running monthly reporting") + try: + monthly_reporting.cycle_all_orgs(run_org_id=reporting_org_id) + except Exception as e: + print(f"Error running monthly reporting: {e}") + + print("Running achievements update") + try: + award_achievements.main() + except Exception as e: + print(f"Error awarding achievements: {e}") + + print("Notifying completion endpoint to update settings cache") + try: + requests.post(f"{APP_URL}/hourly-runner-complete") + except Exception as e: + print(f"Error notifying completion endpoint: {e}") + + print("Hourly scripts complete") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Run hourly scripts") + parser.add_argument("--force", action="store_true") + parser.add_argument("--skip-reporting", action="store_true") + parser.add_argument("--reporting-org-id", type=int, default=None) + args = parser.parse_args() + + run_all_hourly_scripts( + force=args.force, + run_reporting=not args.skip_reporting, + reporting_org_id=args.reporting_org_id, + ) diff --git a/apps/slackbot/scripts/monthly_reporting.py b/apps/slackbot/scripts/monthly_reporting.py new file mode 100644 index 00000000..0b298218 --- /dev/null +++ b/apps/slackbot/scripts/monthly_reporting.py @@ -0,0 +1,796 @@ +import os +import ssl +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +from dataclasses import dataclass +from datetime import datetime +from typing import Dict, List + +import pytz +from f3_data_models.models import ( + AttendanceExpanded, + EventInstanceExpanded, + Org, + Org_x_SlackSpace, + SlackSpace, +) +from f3_data_models.utils import DbManager, get_session +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError +from sqlalchemy import and_, func, literal, select, union_all + +from utilities.database.orm import SlackSettings +from utilities.helper_functions import safe_get + + +@dataclass +class OrgMonthlySummary: + org_id: int + month: datetime + event_count: int + total_posts: int + total_fngs: int + unique_pax_count: int + + +@dataclass +class OrgUserLeaderboard: + basis: str # "month" or "ytd" + org_id: int + org_name: str + user_id: int + f3_name: str + avatar_url: str + post_count: int + total_qs: int + + +# Create the horizontal bar chart (dark + neon styling) +# neon_colors = ["#00F5D4", "#7B2FF7", "#F72585"] # aqua, purple, magenta +NEON_GREEN = "#39FF14" +# Default export size (pixels) and scale multiplier for higher DPI +DEFAULT_IMAGE_WIDTH = 800 +DEFAULT_IMAGE_HEIGHT = 800 +DEFAULT_IMAGE_SCALE = 3 + + +def upload_files_to_slack(file_paths: List[str], settings: SlackSettings, text: str, channel: str): + if not settings.bot_token or not file_paths or not channel: + print("Slack bot token or reporting channel not configured; skipping upload") + return + + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + client = WebClient(token=settings.bot_token, ssl=ssl_context) + + file_list = [] + for fp in file_paths: + with open(fp, "rb") as f: + file_bytes = f.read() + file = { + "filename": fp, + "file": file_bytes, + } + file_list.append(file) + try: + _ = client.files_upload_v2( + channel=channel, + file_uploads=file_list, + initial_comment=text or "F3 Nation Reports", + ) + except SlackApiError as e: + if e.response["error"] == "not_in_channel": + try: + client.conversations_join(channel=channel) + _ = client.files_upload_v2( + channel=channel, + file_uploads=file_list, + initial_comment=text or "F3 Nation Reports", + ) + except SlackApiError as e2: + print(f"Error joining channel or uploading file to Slack: {e2.response['error']}") + else: + print(f"Error uploading file to Slack: {e.response['error']}") + + +def run_reporting_single_org(body: dict, client: WebClient, logger: any, context: dict, region_record: SlackSettings): + org_leaderboard_dict = pull_org_leaderboard_data() + monthly_summary_dict = pull_org_summary_data() + upload_files = [] + if region_record.org_id: + if region_record.reporting_region_leaderboard_enabled and region_record.reporting_region_channel: + if region_record.org_id in org_leaderboard_dict: + upload_files.append(create_post_leaders_plot(org_leaderboard_dict[region_record.org_id])) + if region_record.reporting_region_monthly_summary_enabled: + if region_record.org_id in monthly_summary_dict: + upload_files.append(create_org_monthly_summary(monthly_summary_dict[region_record.org_id])) + # Upload all files for the region + upload_files_to_slack( + upload_files, + region_record, + text="Here are your region's monthly reports!", + channel=region_record.reporting_region_channel, + ) + if region_record.reporting_ao_leaderboard_enabled or region_record.reporting_ao_monthly_summary_enabled: + # AO reports + ao_orgs = DbManager.find_records(Org, filters=[Org.parent_id == region_record.org_id, Org.is_active]) + for ao in ao_orgs: + upload_files = [] + channel = safe_get(ao.meta, "slack_channel_id") or region_record.backblast_destination_channel + if ao.id in org_leaderboard_dict and region_record.reporting_ao_leaderboard_enabled: + upload_files.append(create_post_leaders_plot(org_leaderboard_dict[ao.id])) + if ao.id in monthly_summary_dict and region_record.reporting_ao_monthly_summary_enabled: + upload_files.append(create_org_monthly_summary(monthly_summary_dict[ao.id])) + upload_files_to_slack( + upload_files, + region_record, + text=f"Here are your ({ao.name}) monthly reports!", + channel=channel, + ) + + +def cycle_all_orgs(run_org_id: int = None): + current_time = datetime.now(pytz.timezone("US/Central")) + if run_org_id or (current_time.day == 2 and current_time.hour == 20): + records = DbManager.find_join_records3(Org_x_SlackSpace, Org, SlackSpace, filters=[Org.is_active]) + region_orgs: List[Org] = [r[1] for r in records] + slack_spaces: List[SlackSpace] = [r[2] for r in records] + # org_leaderboard_dict = pull_org_leaderboard_data() + monthly_summary_dict = pull_org_summary_data() + + if run_org_id is None: + # Region reports + for org, slack in zip(region_orgs, slack_spaces, strict=False): + try: + settings = SlackSettings(**slack.settings) + upload_files = [] + # if org.id in org_leaderboard_dict: + # if settings.reporting_region_leaderboard_enabled and settings.reporting_region_channel: + # upload_files.append(create_post_leaders_plot(org_leaderboard_dict[org.id])) + if org.id in monthly_summary_dict: + if settings.reporting_region_monthly_summary_enabled: + upload_files.append(create_org_monthly_summary(monthly_summary_dict[org.id])) + # Upload all files for the region + upload_files_to_slack( + upload_files, + settings, + text=f"Here are your region's monthly summaries! Looking for leaderboards? Find these and more at {os.getenv('STATS_URL')}/stats/region/{org.id}", # noqa: E501 + channel=settings.reporting_region_channel, + ) + + if settings.reporting_ao_monthly_summary_enabled: + # AO reports + ao_orgs = DbManager.find_records(Org, filters=[Org.parent_id == org.id, Org.is_active]) + for ao in ao_orgs: + upload_files = [] + channel = ao.meta.get("slack_channel_id") or settings.backblast_destination_channel + # if ao.id in org_leaderboard_dict and settings.reporting_ao_leaderboard_enabled: + # # channel = ao.meta.get("slack_channel_id") or settings.backblast_destination_channel + # # upload_files.append(create_post_leaders_plot(org_leaderboard_dict[ao.id])) + if ao.id in monthly_summary_dict and settings.reporting_ao_monthly_summary_enabled: + upload_files.append(create_org_monthly_summary(monthly_summary_dict[ao.id])) + upload_files_to_slack( + upload_files, + settings, + text=f"Here is your ({ao.name}) monthly summary! Looking for leaderboards? Find these and more at {os.getenv('STATS_URL')}/stats/region/{org.id}", # noqa: E501 + channel=channel, + ) + except Exception as e: + print(f"Error processing org {org.name} ({org.id}): {e}") + continue + + +def pull_org_leaderboard_data() -> Dict[int, List[OrgUserLeaderboard]]: + session = get_session() + + # Define reusable date range and month expression + prior_month = datetime.now().month - 1 if datetime.now().month > 1 else 12 + prior_year = datetime.now().year if datetime.now().month > 1 else datetime.now().year - 1 + + # Helper to build a scoped query for a given org id column + def build_scoped_query(org_id_col, org_name_col, scope_name: str, basis: str = "month"): + trunc_date = datetime(prior_year, prior_month, 1) if basis == "month" else datetime(prior_year, 1, 1) + query = ( + select( + org_id_col.label("org_id"), + org_name_col.label("org_name"), + literal(basis).label("basis"), + AttendanceExpanded.user_id.label("user_id"), + AttendanceExpanded.f3_name.label("f3_name"), + AttendanceExpanded.avatar_url.label("avatar_url"), + func.count(EventInstanceExpanded.id).label("post_count"), + func.sum(AttendanceExpanded.q_ind + AttendanceExpanded.coq_ind).label("total_qs"), + ) + .join(AttendanceExpanded, AttendanceExpanded.event_instance_id == EventInstanceExpanded.id) + .filter(func.date_trunc(basis, EventInstanceExpanded.start_date) == trunc_date) + .group_by( + org_id_col, + org_name_col, + AttendanceExpanded.user_id, + AttendanceExpanded.f3_name, + AttendanceExpanded.avatar_url, + ) + .order_by(org_id_col, func.count(EventInstanceExpanded.id).desc()) + ) + return query + + # Build queries for AO-scoped and Region-scoped orgs + query_ao_month = build_scoped_query( + EventInstanceExpanded.ao_org_id, EventInstanceExpanded.ao_name, "ao", basis="month" + ) + query_region_month = build_scoped_query( + EventInstanceExpanded.region_org_id, EventInstanceExpanded.region_name, "region", basis="month" + ) + query_ao_ytd = build_scoped_query( + EventInstanceExpanded.ao_org_id, EventInstanceExpanded.ao_name, "ao", basis="year" + ) + query_region_ytd = build_scoped_query( + EventInstanceExpanded.region_org_id, EventInstanceExpanded.region_name, "region", basis="year" + ) + + # Union both scopes so the result includes both AO and Region orgs + final_query = union_all(query_ao_month, query_region_month, query_ao_ytd, query_region_ytd) + + results = session.execute(final_query).all() + + results_dict: Dict[int, List[OrgUserLeaderboard]] = {} + for row in results: + org_id = row.org_id + if org_id not in results_dict: + results_dict[org_id] = [] + results_dict[org_id].append(OrgUserLeaderboard(**row._asdict())) + + session.close() + return results_dict + + +def create_post_leaders_plot(records: List[OrgUserLeaderboard]) -> str: + # guard for empty input + if not records: + print("No records to plot") + return + + from io import BytesIO + + import matplotlib.pyplot as plt + import matplotlib.transforms as mtransforms + import requests + from matplotlib.offsetbox import AnnotationBbox, OffsetImage + from PIL import Image + + # Matplotlib export settings to mirror prior pixel density + DPI = 300 + PX_W = DEFAULT_IMAGE_WIDTH * DEFAULT_IMAGE_SCALE # e.g., 2400 + PX_H = DEFAULT_IMAGE_HEIGHT * DEFAULT_IMAGE_SCALE + FIG_W_IN = PX_W / DPI + FIG_H_IN = PX_H / DPI + + # Avatar/text tuning (in pixels for avatar; text in points) + AVATAR_PX = 60 # down from 100 so it fits inside bars + AVATAR_ZOOM = 1.0 + AVATAR_MARGIN_PX = 300 # gap between avatar and username + USERNAME_FONTSIZE = 24 # slightly smaller to avoid overlap + VALUE_FONTSIZE = 28 # slightly smaller numbers + + def _fetch_image(url: str) -> Image.Image | None: + if not url: + return None + try: + resp = requests.get(url, timeout=6) + resp.raise_for_status() + return Image.open(BytesIO(resp.content)).convert("RGBA") + except Exception: + return None + + def _prepare_avatar(im: Image.Image, target_px: int = AVATAR_PX) -> Image.Image: + """Center-crop to square and resize to a consistent pixel size to avoid giant avatars.""" + if im is None: + return None + w, h = im.size + side = min(w, h) + left = (w - side) // 2 + top = (h - side) // 2 + im = im.crop((left, top, left + side, top + side)) + # hard cap so nothing is huge; consistent across all images + im = im.resize((target_px, target_px), Image.LANCZOS) + return im + + def create_post_leaders_chart( + records: List[OrgUserLeaderboard], + top_n: int = 5, + basis: str = "month", + value_field: str = "post_count", + label: str = "Posts", + bar_color: str = NEON_GREEN, + ): + # filter and sort + sorted_records = [r for r in records if r.basis == basis] + sorted_records = sorted(sorted_records, key=lambda r: getattr(r, value_field), reverse=True)[:top_n] + categories = [r.f3_name for r in sorted_records] + raw_values = [getattr(r, value_field) for r in sorted_records] + images = [r.avatar_url for r in sorted_records] + + # normalize numeric values + values: List[float] = [] + for v in raw_values: + try: + values.append(float(v) if v is not None else 0.0) + except Exception: + values.append(0.0) + + # padding logic similar to original + max_value = max(values + [1.0]) + pad = max(max_value * 0.02, 0.3) + right_pad = pad * 2 + + # figure/axes setup + fig, ax = plt.subplots(figsize=(FIG_W_IN, FIG_H_IN), dpi=DPI) + bg = "#0B1220" + fig.patch.set_facecolor(bg) + ax.set_facecolor(bg) + + y_pos = list(range(len(categories))) + bar_height = 0.8 + ax.barh(y_pos, values, color=bar_color, height=bar_height, zorder=1) + ax.invert_yaxis() # highest value at the top + + # Style: hide ticks/spines/grid + ax.xaxis.set_ticks([]) + ax.yaxis.set_ticks([]) + for spine in ax.spines.values(): + spine.set_visible(False) + + # Title text (org + month/YTD + label) + org_name = sorted_records[0].org_name + " " if sorted_records else "" + if basis == "year": + prior_month_name = "YTD" + else: + prior_month_name = ( + datetime(datetime.now().year, datetime.now().month - 1, 1).strftime("%B") + if datetime.now().month > 1 + else datetime(datetime.now().year - 1, 12, 1).strftime("%B") + ) + ax.set_title(f"{org_name}{prior_month_name} {label} Leaders", color="#FFFFFF", fontsize=30, pad=20) + + # Bars start close to the left edge + ax.set_xlim(0, max_value + right_pad) + ax.margins(x=0) + + # Place avatars and labels using blended transform (axes-fraction x, data y) + # - x as axes fraction keeps consistent insets + # - compute name offset from avatar pixel width to prevent overlap + trans = mtransforms.blended_transform_factory(ax.transAxes, ax.transData) + avatar_x_axes = 0.055 # inside-left + # Convert avatar width + margin (pixels) to axes-fraction to place the name safely to the right + avatar_width_axes = (AVATAR_PX * AVATAR_ZOOM) / PX_W + name_offset_axes = (AVATAR_MARGIN_PX) / PX_W + name_x_axes = avatar_x_axes + avatar_width_axes + name_offset_axes + + # thresholds to decide if text fits inside small bars + inside_fraction_threshold = 0.16 # if bar < 16% of max, move name/number outside + + for i, (name, val, img_url) in enumerate(zip(categories, values, images, strict=False)): + # avatar image (normalized so none are huge) + avatar_im = _fetch_image(img_url) if img_url else None + avatar_im = _prepare_avatar(avatar_im, target_px=AVATAR_PX) if avatar_im is not None else None + if avatar_im is not None: + oi = OffsetImage(avatar_im, zoom=AVATAR_ZOOM) # fixed pixel size; consistent + ab = AnnotationBbox( + oi, + (avatar_x_axes, y_pos[i]), + xycoords=trans, + frameon=False, + box_alignment=(0.0, 0.5), + zorder=2, + ) + ax.add_artist(ab) + + # Decide where to place the username + fraction_of_max = (val / max_value) if max_value > 0 else 0 + name_inside = fraction_of_max >= inside_fraction_threshold + + if name_inside: + # inside the bar near left + ax.text( + name_x_axes, + y_pos[i], + name, + transform=trans, + va="center", + ha="left", + color="#0B1220", + fontsize=USERNAME_FONTSIZE, # smaller username + zorder=3, + ) + else: + # bar too short; put name just outside right in data coords + ax.text( + val + pad, + y_pos[i], + name, + va="center", + ha="left", + color="#FFFFFF", + fontsize=USERNAME_FONTSIZE, + zorder=3, + ) + + # numeric label near the bar end (slightly smaller) + display_label = str(int(val)) if float(val).is_integer() else str(val) + if val > pad * 2: + nx = val - pad + ha = "right" + color = "#0B1220" if name_inside else "#FFFFFF" + else: + nx = val + pad + ha = "left" + color = "#FFFFFF" + + ax.text( + nx, + y_pos[i], + display_label, + va="center", + ha=ha, + color=color, + fontsize=VALUE_FONTSIZE, # a bit smaller than before + fontweight="bold", + zorder=3, + ) + + # save single panel + out_file = f"{basis}_{label}_leaders.png" + fig.savefig(out_file, dpi=DPI, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=0.3) + plt.close(fig) + + # Generate and save the four panels + create_post_leaders_chart( + records, top_n=5, basis="month", value_field="post_count", label="Post", bar_color="#39FF14" + ) + create_post_leaders_chart( + records, top_n=5, basis="year", value_field="post_count", label="Post", bar_color="#14E4FF" + ) + create_post_leaders_chart(records, top_n=5, basis="month", value_field="total_qs", label="Q", bar_color="#FF5733") + create_post_leaders_chart(records, top_n=5, basis="year", value_field="total_qs", label="Q", bar_color="#FFBD33") + + file_name = stitch_2x2( + [ + "month_Post_leaders.png", + "year_Post_leaders.png", + "month_Q_leaders.png", + "year_Q_leaders.png", + ], + out_path="4up_post_leaders.png", + bg_color=(11, 18, 32), + padding=10, + ) + return file_name + + +def stitch_2x2( + image_paths: List[str], + out_path: str = "4up_post_leaders.png", + bg_color: tuple = (11, 18, 32), + padding: int = 0, +): + """Stitch four images into a 2x2 grid and save as a single image. + + - image_paths: list of 4 image file paths in row-major order: [TL, TR, BL, BR] + - out_path: output file path + - bg_color: background RGB tuple for the canvas + - padding: pixels of padding between images + + If the input images differ in size they will be resized to the size of the + first image to produce a uniform grid. + """ + from PIL import Image + + if len(image_paths) != 4: + raise ValueError("image_paths must contain exactly 4 paths (row-major order)") + + imgs = [Image.open(p).convert("RGBA") for p in image_paths] + + # Normalize sizes to the first image + base_w, base_h = imgs[0].size + norm_imgs = [] + for im in imgs: + if im.size != (base_w, base_h): + im = im.resize((base_w, base_h), Image.LANCZOS) + norm_imgs.append(im) + + total_w = base_w * 2 + padding + total_h = base_h * 2 + padding + + canvas = Image.new("RGBA", (total_w, total_h), color=bg_color + (255,)) + + # Positions: (0,0), (base_w+padding,0), (0,base_h+padding), (base_w+padding, base_h+padding) + positions = [(0, 0), (base_w + padding, 0), (0, base_h + padding), (base_w + padding, base_h + padding)] + for im, pos in zip(norm_imgs, positions, strict=False): + canvas.paste(im, pos, im) + + # Save as RGB (flatten alpha) for compatibility + canvas.convert("RGB").save(out_path, dpi=(300, 300)) + return out_path + + +def create_org_monthly_summary(records: List[OrgMonthlySummary]) -> str: + # Build three subplots (Posts, Unique PAX, FNGs) with paired series for current and prior year. + # Only plot points for months that had events (use None for missing months). + import calendar + + import matplotlib.pyplot as plt + import mplcyberpunk # noqa: F401 needed for plt.style. + + plt.style.use("cyberpunk") + fig, axs = plt.subplots(nrows=3, ncols=1, figsize=(12, 10), sharex=True) + + now = datetime.now() + current_year = now.year + current_month = now.month + + # Build rolling 12-month window ending with the prior month + # E.g., if current is March 2026, we show Apr 2025 -> Mar 2026 (but Mar 2026 data may be incomplete) + # So we end with the prior month: Mar 2025 -> Feb 2026 + rolling_months = [] # List of (year, month) tuples + for i in range(12, 0, -1): # 12 months back to prior month + m = current_month - i + y = current_year + while m <= 0: + m += 12 + y -= 1 + rolling_months.append((y, m)) + + # Month labels for the x-axis (abbreviated month names) + month_labels = [calendar.month_abbr[m] for _, m in rolling_months] + + # Determine date range for title (e.g., "Mar 2025 - Feb 2026") + first_month_year, first_month = rolling_months[0] + last_month_year, last_month = rolling_months[-1] + first_abbr = calendar.month_abbr[first_month] + last_abbr = calendar.month_abbr[last_month] + current_range = f"{first_abbr} {first_month_year} - {last_abbr} {last_month_year}" + prior_range = f"{first_abbr} {first_month_year - 1} - {last_abbr} {last_month_year - 1}" + + def aggregate_rolling(year_offset: int): + """Aggregate data for rolling 12 months with given year offset. + year_offset=0 means current rolling period + year_offset=-1 means prior year (same months, one year earlier) + """ + posts: List[int | None] = [] + uniques: List[int | None] = [] + fngs: List[int | None] = [] + for y, m in rolling_months: + target_year = y + year_offset + month_event = next( + (event for event in records if event.month.year == target_year and event.month.month == m), None + ) + if month_event and (month_event.event_count or 0) > 0: + posts.append(month_event.total_posts) + uniques.append(month_event.unique_pax_count) + fngs.append(month_event.total_fngs) + else: + posts.append(None) + uniques.append(None) + fngs.append(None) + return posts, uniques, fngs + + posts_current, uniques_current, fngs_current = aggregate_rolling(0) + posts_prior, uniques_prior, fngs_prior = aggregate_rolling(-1) + + # Colors per metric; prior year uses same color with transparency and dashed style + COLOR_POSTS = "#39FF14" # neon green + COLOR_UNIQUES = "#FF5733" + COLOR_FNGS = "#33D6FF" + + # Subplot 1: Total Posts + ax = axs[0] + ax.plot( + month_labels, + posts_prior, + label=prior_range, + color=COLOR_POSTS, + alpha=0.25, + marker="o", + linestyle="--", + ) + ax.plot( + month_labels, + posts_current, + label=current_range, + color=COLOR_POSTS, + alpha=0.95, + marker="o", + ) + ax.set_title("Total Posts", fontsize=14) + # ax.set_ylabel("Total Posts", fontsize=12) + ax.set_ylim(bottom=0) + ax.legend(loc="upper left") + mplcyberpunk.add_glow_effects(ax=ax, gradient_fill=False) + + # Subplot 2: Unique PAX + ax = axs[1] + ax.plot( + month_labels, + uniques_prior, + label=prior_range, + color=COLOR_UNIQUES, + alpha=0.25, + marker="o", + linestyle="--", + ) + ax.plot( + month_labels, + uniques_current, + label=current_range, + color=COLOR_UNIQUES, + alpha=0.95, + marker="o", + ) + ax.set_title("Unique PAX", fontsize=14) + # ax.set_ylabel("Unique PAX", fontsize=12) + ax.set_ylim(bottom=0) + ax.legend(loc="upper left") + mplcyberpunk.add_glow_effects(ax=ax, gradient_fill=False) + + # Subplot 3: FNGs + ax = axs[2] + ax.plot( + month_labels, + fngs_current, + label=current_range, + color=COLOR_FNGS, + alpha=0.95, + marker="o", + ) + ax.plot( + month_labels, + fngs_prior, + label=prior_range, + color=COLOR_FNGS, + alpha=0.25, + marker="o", + linestyle="--", + ) + + ax.set_title("FNGs", fontsize=14) + # ax.set_ylabel("FNGs", fontsize=12) + ax.set_ylim(bottom=0) + ax.legend(loc="upper left") + mplcyberpunk.add_glow_effects(ax=ax, gradient_fill=False) + + fig.suptitle("Monthly Attendance — Rolling 12 Months", fontsize=16) + # axs[-1].set_xlabel("Month", fontsize=14) + plt.setp(axs[-1].get_xticklabels(), rotation=45) + plt.tight_layout(rect=[0, 0, 1, 0.97]) + plt.savefig("org_monthly_attendance.png", dpi=300) + plt.close() + + return "org_monthly_attendance.png" + + +def pull_org_summary_data() -> Dict[int, List[OrgMonthlySummary]]: + session = get_session() + + # Define reusable date range and month expression + # Pull 2 years of data to support rolling 12-month view with prior year comparison + start_date = datetime(datetime.now().year - 2, 1, 1) + end_date = datetime(datetime.now().year, datetime.now().month, 1) + month_expr = func.date_trunc("month", EventInstanceExpanded.start_date) + + # Helper to build a scoped query for a given org id column + def build_scoped_query(org_id_col, scope_name: str): + # Subquery of events (one row per event) to avoid duplication from Attendance joins + events_subq = ( + select( + org_id_col.label("org_id"), + month_expr.label("month"), + EventInstanceExpanded.id.label("event_id"), + EventInstanceExpanded.pax_count.label("pax_count"), + EventInstanceExpanded.fng_count.label("fng_count"), + ) + .where( + and_( + EventInstanceExpanded.start_date >= start_date, + EventInstanceExpanded.start_date < end_date, + ) + ) + .subquery(f"events_subq_{scope_name}") + ) + + # Aggregate event-level metrics (counts and sums) from the deduplicated events + events_agg = ( + select( + events_subq.c.org_id, + events_subq.c.month, + func.count(events_subq.c.event_id).label("event_count"), + func.sum(events_subq.c.pax_count).label("total_posts"), + func.sum(events_subq.c.fng_count).label("total_fngs"), + ) + .group_by(events_subq.c.org_id, events_subq.c.month) + .subquery(f"events_agg_{scope_name}") + ) + + # Distinct attendance per org, month, user to count unique pax across all events in that month + attendance_distinct = ( + select( + org_id_col.label("org_id"), + month_expr.label("month"), + AttendanceExpanded.user_id.label("user_id"), + ) + .select_from(EventInstanceExpanded) + .join(AttendanceExpanded, AttendanceExpanded.event_instance_id == EventInstanceExpanded.id) + .where( + and_( + EventInstanceExpanded.start_date >= start_date, + EventInstanceExpanded.start_date < end_date, + ) + ) + .distinct() + .subquery(f"attendance_distinct_{scope_name}") + ) + + attendance_agg = ( + select( + attendance_distinct.c.org_id, + attendance_distinct.c.month, + func.count().label("unique_pax_count"), + ) + .group_by(attendance_distinct.c.org_id, attendance_distinct.c.month) + .subquery(f"attendance_agg_{scope_name}") + ) + + # Final join of event aggregates with unique pax counts + scoped_query = select( + events_agg.c.org_id.label("org_id"), + events_agg.c.month.label("month"), + events_agg.c.event_count, + events_agg.c.total_posts, + events_agg.c.total_fngs, + func.coalesce(attendance_agg.c.unique_pax_count, 0).label("unique_pax_count"), + ).select_from( + events_agg.outerjoin( + attendance_agg, + and_( + events_agg.c.org_id == attendance_agg.c.org_id, + events_agg.c.month == attendance_agg.c.month, + ), + ) + ) + return scoped_query + + # Build queries for AO-scoped and Region-scoped orgs + query_ao = build_scoped_query(EventInstanceExpanded.ao_org_id, "ao") + query_region = build_scoped_query(EventInstanceExpanded.region_org_id, "region") + + # Union both scopes so the result includes both AO and Region orgs + final_query = union_all(query_ao, query_region) + + results = session.execute(final_query).all() + + results_dict: Dict[int, List[OrgMonthlySummary]] = {} + for row in results: + org_id = row.org_id + if org_id not in results_dict: + results_dict[org_id] = [] + results_dict[org_id].append(OrgMonthlySummary(**row._asdict())) + + session.close() + return results_dict + + +def run_monthly_summaries(run_org_id: int = None): + summary_dict = pull_org_summary_data() + + if run_org_id is not None and run_org_id in summary_dict: + create_org_monthly_summary(summary_dict[run_org_id]) + else: + for _, records in summary_dict.items(): + create_org_monthly_summary(records) + + +if __name__ == "__main__": + # run_monthly_summaries(run_org_id=38451) + cycle_all_orgs(run_org_id=29307) diff --git a/apps/slackbot/scripts/preblast_reminders.py b/apps/slackbot/scripts/preblast_reminders.py new file mode 100644 index 00000000..2ef5aedd --- /dev/null +++ b/apps/slackbot/scripts/preblast_reminders.py @@ -0,0 +1,187 @@ +import os +import ssl +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import List + +import pytz +from f3_data_models.models import ( + Attendance, + Attendance_x_AttendanceType, + EventInstance, + EventType, + EventType_x_EventInstance, + Org, + Org_x_SlackSpace, + Series_Exception, + SlackSpace, + SlackUser, + User, +) +from f3_data_models.utils import get_session +from slack_sdk.web import WebClient +from sqlalchemy import String, and_, func, or_, select +from sqlalchemy.orm import aliased + +from features.calendar.event_instance import META_DO_NOT_SEND_AUTO_PREBLASTS +from utilities.database.orm import SlackSettings +from utilities.helper_functions import current_date_cst, safe_get +from utilities.slack import actions, orm + +MSG_TEMPLATE = "Hey there, {q_name}! I see you have an upcoming {event_name} Q on {event_date} at {event_ao}. Please click the button below to fill out the preblast form below to let everyone know what to expect. Thanks for leading!" # noqa +MSG_TEMPLATE_NOT_ABLE = " If you're not able to complete the form, I'll still send one out on your behalf." # noqa + + +@dataclass +class PreblastItem: + event: EventInstance + event_type: EventType + org: Org + parent_org: Org + q_name: str + slack_user_id: str + q_avatar_url: str + slack_avatar_url: str + slack_settings: SlackSettings + + +@dataclass +class PreblastList: + items: List[PreblastItem] = field(default_factory=list) + + def pull_data(self, filters: List): + session = get_session() + ParentOrg = aliased(Org) + + firstq_subquery = ( + select( + Attendance.event_instance_id, + Attendance.user_id, + func.row_number() + .over(partition_by=Attendance.event_instance_id, order_by=Attendance.created) + .label("rn"), + ) + .select_from(Attendance) + .join(Attendance_x_AttendanceType, Attendance.id == Attendance_x_AttendanceType.attendance_id) + .filter(Attendance_x_AttendanceType.attendance_type_id == 2) + .alias() + ) + + query = ( + session.query( + EventInstance, + EventType, + Org, + ParentOrg, + func.coalesce(SlackUser.user_name, User.f3_name).label("q_name"), + SlackUser.slack_id, + func.coalesce(User.avatar_url, SlackUser.avatar_url).label("q_avatar_url"), + SlackUser.avatar_url.label("slack_avatar_url"), + SlackSpace.settings, + ) + .select_from(EventInstance) + .join(Org, Org.id == EventInstance.org_id) + .join(EventType_x_EventInstance, EventType_x_EventInstance.event_instance_id == EventInstance.id) + .join(EventType, EventType.id == EventType_x_EventInstance.event_type_id) + .join(ParentOrg, Org.parent_id == ParentOrg.id) + .outerjoin( + firstq_subquery, + and_(EventInstance.id == firstq_subquery.c.event_instance_id, firstq_subquery.c.rn == 1), + ) + .outerjoin(User, User.id == firstq_subquery.c.user_id) + .join(Org_x_SlackSpace, ParentOrg.id == Org_x_SlackSpace.org_id) + .join(SlackSpace, Org_x_SlackSpace.slack_space_id == SlackSpace.id) + .outerjoin(SlackUser, and_(User.id == SlackUser.user_id, SlackUser.slack_team_id == SlackSpace.team_id)) + .filter(*filters) + .order_by(ParentOrg.name, Org.name, EventInstance.start_time) + ) + records = query.all() + self.items = [ + PreblastItem( + event=r[0], + event_type=r[1], + org=r[2], + parent_org=r[3], + q_name=r[4], + slack_user_id=r[5], + q_avatar_url=r[6], + slack_avatar_url=r[7], + slack_settings=SlackSettings(**r[8]), + ) + for r in records + ] + session.expunge_all() + session.close() + + +def send_preblast_reminders(force: bool = False): + # get the current time in US/Central timezone + current_time = datetime.now(pytz.timezone("US/Central")) + preblast_list = PreblastList() + preblast_list.pull_data( + filters=[ + EventInstance.start_date == current_date_cst() + timedelta(days=1), # eventually configurable + EventInstance.preblast_ts.is_(None), # not already sent + or_( + EventInstance.preblast_rich.is_(None), EventInstance.preblast_rich.cast(String) == "null" + ), # not already set + EventInstance.is_active, # not canceled + or_(EventInstance.series_exception.is_(None), EventInstance.series_exception != Series_Exception.closed), # noqa: E501 + ] + ) + preblast_list.items = [item for item in preblast_list.items if item.q_name is not None] + print(f"Found {len(preblast_list.items)} preblast reminders to send.") + + for preblast in preblast_list.items: + # check per-region configured reminder hour, defaulting to 10am CST + reminder_hour = preblast.slack_settings.preblast_reminder_hour_cst + if reminder_hour is None: + reminder_hour = 10 + if current_time.hour != reminder_hour and not force: + continue + # TODO: add some handling for missing stuff + msg = MSG_TEMPLATE.format( + q_name=preblast.q_name, + event_name=preblast.event_type.name, + event_date=preblast.event.start_date.strftime("%m/%d"), + event_ao=preblast.org.name, + ) + if not ( + safe_get(preblast.event.meta, META_DO_NOT_SEND_AUTO_PREBLASTS) + or preblast.slack_settings.automated_preblast_option == "disable" + ): + msg += MSG_TEMPLATE_NOT_ABLE + + slack_bot_token = preblast.slack_settings.bot_token + if slack_bot_token and preblast.slack_user_id: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + slack_client = WebClient(slack_bot_token, ssl=ssl_context) + blocks: List[orm.BaseBlock] = [ + orm.SectionBlock(label=msg), + orm.ActionsBlock( + elements=[ + orm.ButtonElement( + label="Fill Out Preblast", + value=str(preblast.event.id), + style="primary", + action=actions.MSG_EVENT_PREBLAST_BUTTON, + ), + ], + ), + ] + blocks = [b.as_form_field() for b in blocks] + try: + slack_client.chat_postMessage(channel=preblast.slack_user_id, text=msg, blocks=blocks) + except Exception as e: + print(f"Error sending preblast reminder to {preblast.q_name} ({preblast.slack_user_id}): {e}") + continue + + +if __name__ == "__main__": + send_preblast_reminders(force=True) diff --git a/apps/slackbot/scripts/q_lineups.py b/apps/slackbot/scripts/q_lineups.py new file mode 100644 index 00000000..f0247329 --- /dev/null +++ b/apps/slackbot/scripts/q_lineups.py @@ -0,0 +1,375 @@ +import os +import ssl +import sys +from logging import Logger + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +from datetime import date, datetime, timedelta +from typing import Dict, List + +import pytz +from f3_data_models.models import ( + Attendance, + Attendance_x_AttendanceType, + EventInstance, + Org, + Series_Exception, + SlackSpace, +) +from f3_data_models.utils import DbManager +from slack_sdk import WebClient +from slack_sdk.models.metadata import Metadata +from sqlalchemy import or_ + +from scripts.preblast_reminders import PreblastItem, PreblastList +from utilities.database.orm import SlackSettings +from utilities.helper_functions import ( + current_date_cst, + get_user, + safe_convert, + safe_get, +) +from utilities.slack import actions +from utilities.slack.orm import ( + ActionsBlock, + BaseBlock, + ButtonElement, + DividerBlock, + ImageBlock, + SectionBlock, +) + + +def send_lineups(force: bool = False): + # get the current time in US/Central timezone + current_time = datetime.now(pytz.timezone("US/Central")) + slack_spaces = DbManager.find_records(SlackSpace, filters=[True]) + slack_settings_list: List[SlackSettings] = [ + SlackSettings(**s.settings) for s in slack_spaces if safe_get(s.settings, "org_id") + ] + + # find all slack settings where send_q_lineups is True and the current time matches the configured day and hour + include_region_orgs = { + r.org_id: r + for r in slack_settings_list + if r.send_q_lineups + and ( + ( + (r.send_q_lineups_day or 6) == current_time.weekday() + and (r.send_q_lineups_hour_cst or 17) == current_time.hour + ) + or force + ) + } + + if include_region_orgs: + # Figure out current and next weeks based on current start of day + # I have the week start on Monday and end on Sunday - if this is run on Sunday, "current" week will start tomorrow # noqa + current_date = current_date_cst() + start_of_next_week = current_date + timedelta(days=7 - current_date.weekday()) + end_of_next_week = start_of_next_week + timedelta(days=6) + event_list = PreblastList() + event_list.pull_data( + filters=[ + EventInstance.start_date >= start_of_next_week, + EventInstance.start_date <= end_of_next_week, + EventInstance.is_active, # not canceled + or_(Org.id.in_(include_region_orgs), Org.parent_id.in_(include_region_orgs)), + # may want to filter out pre-events? + ] + ) + + # Build the event organization list + event_org_list: Dict[int, List[PreblastItem]] = {} + for event in event_list.items: + event_org_list.setdefault(event.org.id, []).append(event) + + for region_id, slack_settings in include_region_orgs.items(): + if slack_settings.send_q_lineups_method == "yes_per_ao": + # Send per AO + for org_id in event_org_list: + org_events = event_org_list[org_id] + org_record = org_events[0].org + if org_record.parent_id == region_id: + blocks = [ + SectionBlock( + label=f"*Hello HIMs of {org_record.name}! Here is your Q lineup for the week*" + ).as_form_field(), + DividerBlock().as_form_field(), + ] + blocks.extend(build_lineup_blocks(org_events, org_record)) + try: + send_q_lineup_message( + org_record, + blocks, + slack_settings, + start_of_next_week, + end_of_next_week, + ) + except Exception: + print(f"Error sending Q lineup for AO {org_record.name} ({org_record.id})") + continue + elif slack_settings.send_q_lineups_method == "yes_for_all": + # Send combined for region + region_record = DbManager.get(Org, region_id) + blocks: List[dict] = [ + SectionBlock( + label=f"*Hello HIMs of {region_record.name}! Here are your Q lineups for the week*\n\n" + ).as_form_field() + ] + for org_id in event_org_list: + org_events = event_org_list[org_id] + org_record = org_events[0].org + if org_record.parent_id == region_id: + blocks.extend( + [ + SectionBlock(label=f"*{org_record.name}:*").as_form_field(), + DividerBlock().as_form_field(), + ] + ) + blocks.extend(build_lineup_blocks(org_events, org_record)) + try: + send_q_lineup_message( + region_record, + blocks, + slack_settings, + start_of_next_week, + end_of_next_week, + ) + except Exception as e: + print(f"Error sending Q lineup for region {region_record.name} ({region_record.id}): {e}") + continue + + +def build_lineup_blocks(org_events: List[PreblastItem], org: Org) -> List[dict]: + org_events.sort( + key=lambda x: ( + x.event.start_date + + timedelta(hours=safe_convert(x.event.start_time[:2] if x.event.start_time else "0", int)) + ) + ) + blocks: List[BaseBlock] = [] + + for event in org_events: + if event.event.series_exception == Series_Exception.closed: + label = f"*{event.event.start_date.strftime('%A, %m/%d')}*\n{event.event_type.name} {event.event.start_time}\n*CLOSED.*" # noqa + accessory = None + if event.q_name: + q_label = f"@{event.q_name}" # f"<@{event.slack_user_id}>" if event.slack_user_id else + label = f"*{event.event.start_date.strftime('%A, %m/%d')}*\n{event.event_type.name} {event.event.start_time}\n{q_label}" # noqa + image_url = ( + event.slack_avatar_url + or event.q_avatar_url + or "https://www.publicdomainpictures.net/pictures/40000/t2/question-mark.jpg" + ) + accessory = ImageBlock( + image_url=image_url, + alt_text="Q Lineup", + ) + else: + label = f"*{event.event.start_date.strftime('%A, %m/%d')}*\n{event.event_type.name} {event.event.start_time}\n*OPEN!*" # noqa + # image_url = "https://www.publicdomainpictures.net/pictures/40000/t2/question-mark.jpg" + accessory = ButtonElement( + label=":calendar: Sign Up to Lead!", + action=f"{actions.LINEUP_SIGNUP_BUTTON}_{event.event.id}", + value=str(event.event.id), + style="primary", + ) + blocks.append( + SectionBlock( + label=label, + element=accessory, + ) + ) + # blocks.append( + # ActionsBlock( + # elements=[ + # ButtonElement( + # label=":calendar: Open Calendar", + # action=actions.OPEN_CALENDAR_MSG_BUTTON, + # ) + # ] + # ) + # ) + return [b.as_form_field() for b in blocks] + + +HEADER_BLOCKS: List[BaseBlock] = [ + SectionBlock(label="Here is your Q lineup for the week\n\n*Weekly Q Lineup:*"), + DividerBlock(), +] + + +def send_q_lineup_message( + org: Org, + blocks: List[dict], + slack_settings: SlackSettings, + week_start: date = None, + week_end: date = None, + update_channel_id: str = None, + update_ts: str = None, +): + calendar_button_block = ActionsBlock( + elements=[ + ButtonElement( + label=":calendar: Open Calendar", + action=actions.OPEN_CALENDAR_MSG_BUTTON, + ) + ] + ).as_form_field() + blocks.append(calendar_button_block) + + slack_bot_token = slack_settings.bot_token + metadata = Metadata( + event_type="q_lineup", event_payload={"send_q_lineups_method": slack_settings.send_q_lineups_method} + ) + if week_start and week_end: + metadata.event_payload["week_start"] = week_start.strftime("%y-%m-%d") + metadata.event_payload["week_end"] = week_end.strftime("%y-%m-%d") + if slack_bot_token: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + slack_client = WebClient(slack_bot_token, ssl=ssl_context) + if update_channel_id: + channel_id = update_channel_id + if slack_settings.send_q_lineups_method == "yes_per_ao" and safe_get(org.meta, "slack_channel_id"): + channel_id = org.meta.get("slack_channel_id") + elif slack_settings.send_q_lineups_method == "yes_for_all" and slack_settings.send_q_lineups_channel: + channel_id = slack_settings.send_q_lineups_channel + else: + channel_id = None + for attempt in range(2): + try: + if channel_id: + if update_channel_id and update_ts: + # Update the existing message + slack_client.chat_update( + channel=channel_id, + ts=update_ts, + text="Q Lineup", + blocks=blocks, + metadata=metadata, + ) + else: + resp = slack_client.chat_postMessage( + channel=channel_id, + text="Q Lineup", + blocks=blocks, + metadata=metadata, + ) + org.meta = org.meta or {} + org.meta["q_lineup_ts"] = resp["ts"] + DbManager.update_record(Org, org.id, {Org.meta: org.meta}) + break # successfully sent, break out of retry loop + except Exception as e: + if channel_id and attempt == 0: + print(f"Error sending message to channel {channel_id}, trying to join channel and resend: {e}") + slack_client.conversations_join(channel=channel_id) + else: + print(f"Error sending Q lineup message for org {org.name} ({org.id}): {e}") + break # ran out of tries, break out of retry loop + + +def handle_lineup_signup(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + event_instance_id = int(body["actions"][0]["value"]) + slack_user_id = body["user"]["id"] + slack_channel_id = body["channel"]["id"] + user_id = get_user(slack_user_id, region_record, client, logger).user_id + + metadata = safe_get(body, "message", "metadata", "event_payload") + week_start = safe_get(metadata, "week_start") + week_end = safe_get(metadata, "week_end") + send_q_lineup_method = safe_get(metadata, "send_q_lineups_method") + if week_start and week_end: + this_week_start = datetime.strptime(week_start, "%y-%m-%d").date() + this_week_end = datetime.strptime(week_end, "%y-%m-%d").date() + else: + # Default to the current week + tomorrow_day_of_week = (current_date_cst() + timedelta(days=1)).weekday() + this_week_start = current_date_cst() + timedelta(days=-tomorrow_day_of_week) + this_week_end = current_date_cst() + timedelta(days=7 - tomorrow_day_of_week) + + preblast_info = PreblastList() + preblast_info.pull_data(filters=[EventInstance.id == event_instance_id]) + event_info: PreblastItem = safe_get(preblast_info.items, 0) + # Check if a user is already signed up for the event + attendance_record = DbManager.find_records( + Attendance, + filters=[Attendance.event_instance_id == event_instance_id, Attendance.user_id == user_id], + joinedloads=[Attendance.attendance_x_attendance_types], + ) + if event_info.q_name: + client.chat_postEphemeral( + user=slack_user_id, + channel=slack_channel_id, + text=f"Sorry, {event_info.q_name} already signed up for this event.", + ) + else: + if attendance_record: + if 2 not in attendance_record[0].attendance_x_attendance_types: + DbManager.create_record( + Attendance_x_AttendanceType(attendance_id=attendance_record[0].id, attendance_type_id=2) + ) + else: + DbManager.create_record( + Attendance( + event_instance_id=event_instance_id, + user_id=user_id, + attendance_x_attendance_types=[Attendance_x_AttendanceType(attendance_type_id=2)], + is_planned=True, + ) + ) + # Update the Q Lineup + if send_q_lineup_method == "yes_per_ao": + org_info = PreblastList() + org_info.pull_data( + filters=[ + EventInstance.org_id == event_info.org.id, + EventInstance.start_date >= this_week_start, + EventInstance.start_date <= this_week_end, + EventInstance.is_active, + ], + ) + blocks = build_lineup_blocks(org_info.items, event_info.org) + send_q_lineup_message( + event_info.org, + blocks, + region_record, + week_start=this_week_start, + week_end=this_week_end, + update_channel_id=slack_channel_id, + update_ts=body["message"]["ts"], + ) + elif send_q_lineup_method == "yes_for_all": + org_info = PreblastList() + org_info.pull_data( + filters=[ + Org.parent_id == event_info.org.parent_id, + EventInstance.start_date >= this_week_start, + EventInstance.start_date <= this_week_end, + EventInstance.is_active, + ], + ) + blocks = [ + SectionBlock(label="*Here are your Q lineups for the week*\n\n").as_form_field(), + ] + for org in {e.org for e in org_info.items}: + blocks.append(DividerBlock().as_form_field()) + blocks.append(SectionBlock(label=f"*{org.name}:*").as_form_field()) + events = [e for e in org_info.items if e.org.id == org.id] + blocks.extend(build_lineup_blocks(events, org)) + send_q_lineup_message( + event_info.org, + blocks, + region_record, + week_start=this_week_start, + week_end=this_week_end, + update_channel_id=slack_channel_id, + update_ts=body["message"]["ts"], + ) + + +if __name__ == "__main__": + send_lineups(force=True) diff --git a/apps/slackbot/scripts/requirements.txt b/apps/slackbot/scripts/requirements.txt new file mode 100644 index 00000000..38fe4da4 --- /dev/null +++ b/apps/slackbot/scripts/requirements.txt @@ -0,0 +1,191 @@ +aiofiles==25.1.0 ; python_version >= "3.12" and python_version < "4.0" +aiohappyeyeballs==2.6.1 ; python_version >= "3.12" and python_version < "4.0" +aiohttp==3.13.5 ; python_version >= "3.12" and python_version < "4.0" +aiosignal==1.4.0 ; python_version >= "3.12" and python_version < "4.0" +alembic-postgresql-enum==1.10.0 ; python_version >= "3.12" and python_version < "4.0" +alembic==1.18.4 ; python_version >= "3.12" and python_version < "4.0" +annotated-types==0.7.0 ; python_version >= "3.12" and python_version < "4.0" +anyio==4.13.0 ; python_version >= "3.12" and python_version < "4.0" +appnope==0.1.4 ; python_version >= "3.12" and python_version < "4.0" and platform_system == "Darwin" +argcomplete==3.6.3 ; python_version >= "3.12" and python_version < "4.0" +asn1crypto==1.5.1 ; python_version >= "3.12" and python_version < "4.0" +asttokens==3.0.1 ; python_version >= "3.12" and python_version < "4.0" +attrs==26.1.0 ; python_version >= "3.12" and python_version < "4.0" +beautifulsoup4==4.14.3 ; python_version >= "3.12" and python_version < "4.0" +bleach==6.3.0 ; python_version >= "3.12" and python_version < "4.0" +blinker==1.9.0 ; python_version >= "3.12" and python_version < "4.0" +certifi==2026.4.22 ; python_version >= "3.12" and python_version < "4.0" +cffi==2.0.0 ; python_version >= "3.12" and python_version < "4.0" and (implementation_name == "pypy" or platform_python_implementation != "PyPy") +cfgv==3.5.0 ; python_version >= "3.12" and python_version < "4.0" +chardet==7.4.3 ; python_version >= "3.12" and python_version < "4.0" +charset-normalizer==3.4.7 ; python_version >= "3.12" and python_version < "4.0" +choreographer==1.3.0 ; python_version >= "3.12" and python_version < "4.0" +click-option-group==0.5.9 ; python_version >= "3.12" and python_version < "4.0" +click==8.1.8 ; python_version >= "3.12" and python_version < "4.0" +cloud-sql-python-connector==1.20.2 ; python_version >= "3.12" and python_version < "4.0" +cloudevents==1.12.0 ; python_version >= "3.12" and python_version < "4.0" +colorama==0.4.6 ; python_version >= "3.12" and python_version < "4.0" +comm==0.2.3 ; python_version >= "3.12" and python_version < "4.0" +commitizen==4.16.2 ; python_version >= "3.12" and python_version < "4.0" +contourpy==1.3.3 ; python_version >= "3.12" and python_version < "4.0" +cryptography==48.0.0 ; python_version >= "3.12" and python_version < "4.0" +cssselect==1.4.0 ; python_version >= "3.12" and python_version < "4.0" +cssutils==2.15.0 ; python_version >= "3.12" and python_version < "4.0" +cycler==0.12.1 ; python_version >= "3.12" and python_version < "4.0" +dataframe-image==0.2.7 ; python_version >= "3.12" and python_version < "4.0" +datetime==5.5 ; python_version >= "3.12" and python_version < "4.0" +debugpy==1.8.20 ; python_version >= "3.12" and python_version < "4.0" +decli==0.6.3 ; python_version >= "3.12" and python_version < "4.0" +decorator==5.2.1 ; python_version >= "3.12" and python_version < "4.0" +defusedxml==0.7.1 ; python_version >= "3.12" and python_version < "4.0" +deprecated==1.3.1 ; python_version >= "3.12" and python_version < "4.0" +deprecation==2.1.0 ; python_version >= "3.12" and python_version < "4.0" +distlib==0.4.0 ; python_version >= "3.12" and python_version < "4.0" +dnspython==2.8.0 ; python_version >= "3.12" and python_version < "4.0" +dotty-dict==1.3.1 ; python_version >= "3.12" and python_version < "4.0" +ecdsa==0.19.2 ; python_version >= "3.12" and python_version < "4.0" +encutils==1.0.0 ; python_version >= "3.12" and python_version < "4.0" +executing==2.2.1 ; python_version >= "3.12" and python_version < "4.0" +f3-data-models==1.0.9 ; python_version >= "3.12" and python_version < "4.0" +fastjsonschema==2.21.2 ; python_version >= "3.12" and python_version < "4.0" +filelock==3.29.0 ; python_version >= "3.12" and python_version < "4.0" +flask==3.1.3 ; python_version >= "3.12" and python_version < "4.0" +fonttools==4.63.0 ; python_version >= "3.12" and python_version < "4.0" +frozenlist==1.8.0 ; python_version >= "3.12" and python_version < "4.0" +functions-framework==3.10.1 ; python_version >= "3.12" and python_version < "4.0" +gitdb==4.0.12 ; python_version >= "3.12" and python_version < "4.0" +gitpython==3.1.50 ; python_version >= "3.12" and python_version < "4.0" +google-api-core==2.30.3 ; python_version >= "3.12" and python_version < "4.0" +google-auth==2.52.0 ; python_version >= "3.12" and python_version < "4.0" +google-cloud-appengine-logging==1.9.0 ; python_version >= "3.12" and python_version < "4.0" +google-cloud-audit-log==0.5.0 ; python_version >= "3.12" and python_version < "4.0" +google-cloud-core==2.6.0 ; python_version >= "3.12" and python_version < "4.0" +google-cloud-logging==3.15.0 ; python_version >= "3.12" and python_version < "4.0" +googleapis-common-protos==1.75.0 ; python_version >= "3.12" and python_version < "4.0" +graphviz==0.20.3 ; python_version >= "3.12" and python_version < "4.0" +greenlet==3.5.0 ; python_version >= "3.12" and python_version < "4.0" +grpc-google-iam-v1==0.14.4 ; python_version >= "3.12" and python_version < "4.0" +grpcio-status==1.80.0 ; python_version >= "3.12" and python_version < "4.0" +grpcio==1.80.0 ; python_version >= "3.12" and python_version < "4.0" +gunicorn==26.0.0 ; python_version >= "3.12" and python_version < "4.0" +h11==0.16.0 ; python_version >= "3.12" and python_version < "4.0" +identify==2.6.19 ; python_version >= "3.12" and python_version < "4.0" +idna==3.15 ; python_version >= "3.12" and python_version < "4.0" +importlib-metadata==8.7.1 ; python_version >= "3.12" and python_version < "4.0" +importlib-resources==6.5.2 ; python_version >= "3.12" and python_version < "4.0" +ipykernel==6.31.0 ; python_version >= "3.12" and python_version < "4.0" +ipython-pygments-lexers==1.1.1 ; python_version >= "3.12" and python_version < "4.0" +ipython==9.13.0 ; python_version >= "3.12" and python_version < "4.0" +itsdangerous==2.2.0 ; python_version >= "3.12" and python_version < "4.0" +jedi==0.20.0 ; python_version >= "3.12" and python_version < "4.0" +jinja2==3.1.6 ; python_version >= "3.12" and python_version < "4.0" +jsonschema-specifications==2025.9.1 ; python_version >= "3.12" and python_version < "4.0" +jsonschema==4.26.0 ; python_version >= "3.12" and python_version < "4.0" +jupyter-client==8.8.0 ; python_version >= "3.12" and python_version < "4.0" +jupyter-core==5.9.1 ; python_version >= "3.12" and python_version < "4.0" +jupyterlab-pygments==0.3.0 ; python_version >= "3.12" and python_version < "4.0" +kaleido==1.3.0 ; python_version >= "3.12" and python_version < "4.0" +kiwisolver==1.5.0 ; python_version >= "3.12" and python_version < "4.0" +logistro==2.0.1 ; python_version >= "3.12" and python_version < "4.0" +lxml==6.1.0 ; python_version >= "3.12" and python_version < "4.0" +mako==1.3.12 ; python_version >= "3.12" and python_version < "4.0" +markdown-it-py==4.2.0 ; python_version >= "3.12" and python_version < "4.0" +markupsafe==3.0.3 ; python_version >= "3.12" and python_version < "4.0" +matplotlib-inline==0.2.2 ; python_version >= "3.12" and python_version < "4.0" +matplotlib==3.10.9 ; python_version >= "3.12" and python_version < "4.0" +mdurl==0.1.2 ; python_version >= "3.12" and python_version < "4.0" +mistune==3.2.1 ; python_version >= "3.12" and python_version < "4.0" +more-itertools==11.0.2 ; python_version >= "3.12" and python_version < "4.0" +mplcyberpunk==0.7.6 ; python_version >= "3.12" and python_version < "4.0" +multidict==6.7.1 ; python_version >= "3.12" and python_version < "4.0" +nbclient==0.10.4 ; python_version >= "3.12" and python_version < "4.0" +nbconvert==7.17.1 ; python_version >= "3.12" and python_version < "4.0" +nbformat==5.10.4 ; python_version >= "3.12" and python_version < "4.0" +nest-asyncio==1.6.0 ; python_version >= "3.12" and python_version < "4.0" +nodeenv==1.10.0 ; python_version >= "3.12" and python_version < "4.0" +numpy==2.4.4 ; python_version >= "3.12" and python_version < "4.0" +oauthlib==3.3.1 ; python_version >= "3.12" and python_version < "4.0" +opentelemetry-api==1.41.1 ; python_version >= "3.12" and python_version < "4.0" +orjson==3.11.9 ; python_version >= "3.12" and python_version < "4.0" +packaging==26.2 ; python_version >= "3.12" and python_version < "4.0" +pandas==2.3.3 ; python_version >= "3.12" and python_version < "4.0" +pandocfilters==1.5.1 ; python_version >= "3.12" and python_version < "4.0" +parso==0.8.7 ; python_version >= "3.12" and python_version < "4.0" +pexpect==4.9.0 ; python_version >= "3.12" and python_version < "4.0" and sys_platform != "win32" and sys_platform != "emscripten" +pg8000==1.31.5 ; python_version >= "3.12" and python_version < "4.0" +pillow-heif==0.15.0 ; python_version >= "3.12" and python_version < "4.0" +pillow==12.2.0 ; python_version >= "3.12" and python_version < "4.0" +platformdirs==4.9.6 ; python_version >= "3.12" and python_version < "4.0" +playwright==1.59.0 ; python_version >= "3.12" and python_version < "4.0" +pre-commit==4.6.0 ; python_version >= "3.12" and python_version < "4.0" +prompt-toolkit==3.0.51 ; python_version >= "3.12" and python_version < "4.0" +propcache==0.5.2 ; python_version >= "3.12" and python_version < "4.0" +proto-plus==1.28.0 ; python_version >= "3.12" and python_version < "4.0" +protobuf==6.33.6 ; python_version >= "3.12" and python_version < "4.0" +psutil==7.2.2 ; python_version >= "3.12" and python_version < "4.0" +psycopg2-binary==2.9.12 ; python_version >= "3.12" and python_version < "4.0" +ptyprocess==0.7.0 ; python_version >= "3.12" and python_version < "4.0" and sys_platform != "win32" and sys_platform != "emscripten" +pure-eval==0.2.3 ; python_version >= "3.12" and python_version < "4.0" +pyasn1-modules==0.4.2 ; python_version >= "3.12" and python_version < "4.0" +pyasn1==0.6.3 ; python_version >= "3.12" and python_version < "4.0" +pycparser==3.0 ; python_version >= "3.12" and python_version < "4.0" and (platform_python_implementation != "PyPy" or implementation_name == "pypy") and implementation_name != "PyPy" +pydantic-core==2.46.4 ; python_version >= "3.12" and python_version < "4.0" +pydantic==2.13.4 ; python_version >= "3.12" and python_version < "4.0" +pydot==4.0.1 ; python_version >= "3.12" and python_version < "4.0" +pyee==13.0.1 ; python_version >= "3.12" and python_version < "4.0" +pygments==2.20.0 ; python_version >= "3.12" and python_version < "4.0" +pyparsing==3.3.2 ; python_version >= "3.12" and python_version < "4.0" +python-dateutil==2.9.0.post0 ; python_version >= "3.12" and python_version < "4.0" +python-discovery==1.3.1 ; python_version >= "3.12" and python_version < "4.0" +python-dotenv==1.2.2 ; python_version >= "3.12" and python_version < "4.0" +python-gitlab==6.5.0 ; python_version >= "3.12" and python_version < "4.0" +python-http-client==3.3.7 ; python_version >= "3.12" and python_version < "4.0" +python-semantic-release==10.5.3 ; python_version >= "3.12" and python_version < "4.0" +pytz==2026.2 ; python_version >= "3.12" and python_version < "4.0" +pyyaml==6.0.3 ; python_version >= "3.12" and python_version < "4.0" +pyzmq==27.1.0 ; python_version >= "3.12" and python_version < "4.0" +questionary==2.1.1 ; python_version >= "3.12" and python_version < "4.0" +referencing==0.37.0 ; python_version >= "3.12" and python_version < "4.0" +requests-oauthlib==1.3.1 ; python_version >= "3.12" and python_version < "4.0" +requests-toolbelt==1.0.0 ; python_version >= "3.12" and python_version < "4.0" +requests==2.34.2 ; python_version >= "3.12" and python_version < "4.0" +rich==14.3.4 ; python_version >= "3.12" and python_version < "4.0" +rpds-py==0.30.0 ; python_version >= "3.12" and python_version < "4.0" +scramp==1.4.8 ; python_version >= "3.12" and python_version < "4.0" +sendgrid==6.12.4 ; python_version >= "3.12" and python_version < "4.0" +setuptools==82.0.1 ; python_version >= "3.12" and python_version < "4.0" +shellingham==1.5.4 ; python_version >= "3.12" and python_version < "4.0" +simplejson==4.1.1 ; python_version >= "3.12" and python_version < "4.0" +six==1.17.0 ; python_version >= "3.12" and python_version < "4.0" +slack-bolt==1.28.0 ; python_version >= "3.12" and python_version < "4.0" +slack-sdk==3.41.0 ; python_version >= "3.12" and python_version < "4.0" +smmap==5.0.3 ; python_version >= "3.12" and python_version < "4.0" +soupsieve==2.8.3 ; python_version >= "3.12" and python_version < "4.0" +sqlalchemy-citext==1.8.0 ; python_version >= "3.12" and python_version < "4.0" +sqlalchemy-schemadisplay==2.0 ; python_version >= "3.12" and python_version < "4.0" +sqlalchemy-utils==0.41.2 ; python_version >= "3.12" and python_version < "4.0" +sqlalchemy==2.0.49 ; python_version >= "3.12" and python_version < "4.0" +sqlmodel==0.0.22 ; python_version >= "3.12" and python_version < "4.0" +stack-data==0.6.3 ; python_version >= "3.12" and python_version < "4.0" +starlette==0.52.1 ; python_version >= "3.12" and python_version < "4.0" +termcolor==3.3.0 ; python_version >= "3.12" and python_version < "4.0" +tinycss2==1.4.0 ; python_version >= "3.12" and python_version < "4.0" +tomlkit==0.13.3 ; python_version >= "3.12" and python_version < "4.0" +tornado==6.5.5 ; python_version >= "3.12" and python_version < "4.0" +traitlets==5.15.0 ; python_version >= "3.12" and python_version < "4.0" +typing-extensions==4.15.0 ; python_version >= "3.12" and python_version < "4.0" +typing-inspection==0.4.2 ; python_version >= "3.12" and python_version < "4.0" +tzdata==2026.2 ; python_version >= "3.12" and python_version < "4.0" +urllib3==2.7.0 ; python_version >= "3.12" and python_version < "4.0" +uvicorn-worker==0.4.0 ; python_version >= "3.12" and python_version < "4.0" +uvicorn==0.47.0 ; python_version >= "3.12" and python_version < "4.0" +virtualenv==21.3.3 ; python_version >= "3.12" and python_version < "4.0" +watchdog==6.0.0 ; python_version >= "3.12" and python_version < "4.0" +watchfiles==1.1.1 ; python_version >= "3.12" and python_version < "4.0" +wcwidth==0.7.0 ; python_version >= "3.12" and python_version < "4.0" +webencodings==0.5.1 ; python_version >= "3.12" and python_version < "4.0" +werkzeug==3.1.8 ; python_version >= "3.12" and python_version < "4.0" +wrapt==2.1.2 ; python_version >= "3.12" and python_version < "4.0" +yarl==1.23.0 ; python_version >= "3.12" and python_version < "4.0" +zipp==3.23.1 ; python_version >= "3.12" and python_version < "4.0" +zope-interface==8.4 ; python_version >= "3.12" and python_version < "4.0" diff --git a/apps/slackbot/scripts/update_slack_users.py b/apps/slackbot/scripts/update_slack_users.py new file mode 100644 index 00000000..f54c812e --- /dev/null +++ b/apps/slackbot/scripts/update_slack_users.py @@ -0,0 +1,121 @@ +import os +import sys + +from sqlalchemy import case, select, update + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) +from f3_data_models.models import Attendance, EventInstance, Org, Org_x_SlackSpace, SlackSpace, SlackUser, User +from f3_data_models.utils import DbManager, get_session +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from utilities.helper_functions import create_user, safe_get + + +def update_slack_users(force=False): + """ + Update Slack users in the database with their latest information from Slack. + """ + all_slack_users = DbManager.find_records(cls=SlackUser, filters=[True]) + slack_user_dict = {user.slack_id: user for user in all_slack_users} + all_slack_spaces: list[tuple[SlackSpace, Org_x_SlackSpace]] = DbManager.find_join_records2( + SlackSpace, + Org_x_SlackSpace, + filters=[True], + ) + + for slack_space_record in all_slack_spaces: + slack_space = slack_space_record[0] + region_org_record = slack_space_record[1] + client = WebClient(token=slack_space.settings.get("bot_token")) + try: + users: list[dict] = [] + + response = client.users_list() + users = response["members"] + while response.get("response_metadata", {}).get("next_cursor"): + response = client.users_list(cursor=response["response_metadata"]["next_cursor"]) + users.extend(response["members"]) + + for user in users: + if user["is_bot"] or user["id"] == "USLACKBOT": + continue # Skip bots and the Slackbot + + slack_user = slack_user_dict.get(user["id"]) + if not safe_get(slack_user, "user_id"): + print(f"Creating new Slack user {user['id']} ({user.get('name')})") + slack_user = create_user(user, region_org_record.org_id) + elif slack_user.slack_updated and slack_user.slack_updated >= user["updated"] and not force: + continue + else: + update_fields = { + SlackUser.user_name: safe_get(user, "profile", "display_name") + or safe_get(user, "profile", "real_name") + or safe_get(user, "name"), + SlackUser.slack_updated: safe_get(user, "updated"), + SlackUser.is_admin: safe_get(user, "is_admin") or False, + SlackUser.is_owner: safe_get(user, "is_owner") or False, + SlackUser.is_bot: safe_get(user, "is_bot") or False, + SlackUser.avatar_url: safe_get(user, "profile", "image_512"), + } + DbManager.update_record(SlackUser, slack_user.id, update_fields) + + print("Slack users updated successfully.") + + except SlackApiError as e: + print(f"Error updating Slack users for {slack_space.workspace_name}: {e.response['error']}") + continue + + +def update_home_regions(): + users_without_home_region = DbManager.find_records(cls=User, filters=[User.home_region_id.is_(None)]) + print(f"Found {len(users_without_home_region)} users without home region.") + # option 1: find the first event they attended and set that region as their home region + # option 2: find the org_id from their slack user associations + if users_without_home_region: + with get_session() as session: + first_event_subquery = ( + select( + case( + (Org.org_type == "region", Org.id), + else_=Org.parent_id, + ).label("region_id") + ) + .select_from(EventInstance) + .join(Attendance, Attendance.event_instance_id == EventInstance.id) + .join(Org, Org.id == EventInstance.org_id) + .filter(Attendance.user_id == User.id) + .order_by(EventInstance.start_date, EventInstance.start_time) + .limit(1) + .correlate(User) + ) + + slack_space_subquery = ( + select(Org_x_SlackSpace.org_id) + .join(SlackSpace, SlackSpace.id == Org_x_SlackSpace.slack_space_id) + .join(SlackUser, SlackUser.slack_team_id == SlackSpace.team_id) + .filter(SlackUser.user_id == User.id) + .limit(1) + .correlate(User) + ) + + update_query = ( + update(User) + .where(User.id.in_([user.id for user in users_without_home_region])) + .values( + { + User.home_region_id: case( + (first_event_subquery.exists(), first_event_subquery.scalar_subquery()), + (slack_space_subquery.exists(), slack_space_subquery.scalar_subquery()), + else_=User.home_region_id, + ) + } + ) + ) + session.execute(update_query) + session.commit() + + +if __name__ == "__main__": + update_slack_users() + update_home_regions() diff --git a/apps/slackbot/scripts/update_special_events.py b/apps/slackbot/scripts/update_special_events.py new file mode 100644 index 00000000..e5533138 --- /dev/null +++ b/apps/slackbot/scripts/update_special_events.py @@ -0,0 +1,86 @@ +import os +import ssl +import sys +from datetime import timedelta + +from slack_sdk import WebClient + +from utilities.helper_functions import current_date_cst + +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +from typing import List + +from f3_data_models.models import ( + EventInstance, + Org, + Org_Type, +) +from f3_data_models.utils import DbManager + +from utilities.database.orm import SlackSettings +from utilities.slack import orm + +# TODO: future option for this to live on a Canvas?!? + + +def create_special_events_blocks(events: List[EventInstance], slack_settings: SlackSettings) -> List[dict]: + blocks: List[orm.BaseBlock] = [] + for i, event in enumerate(events): + text = ( + f"*{i + 1}. {event.name}*\n{event.start_date.strftime('%A, %B %d')} - {event.start_time}\n{event.org.name}" # noqa + ) + + if event.preblast_ts: + # TODO: need to make this work for region-level events + if slack_settings.default_preblast_destination == "specified_channel": + channel_id = slack_settings.preblast_destination_channel + else: + channel_id = event.org.meta.get("slack_channel_id") + + if channel_id: + text += f"\n" # noqa + blocks.append( + orm.SectionBlock( + label=text, + # TODO: add HC button? + ) + ) + blocks = [block.as_form_field() for block in blocks] + return blocks + + +def update_special_events(): + regions: List[Org] = DbManager.find_records( + cls=Org, filters=[Org.org_type == Org_Type.region], joinedloads=[Org.slack_space] + ) + + for region in regions: + slack_settings = SlackSettings(**region.slack_space.settings) + if slack_settings.special_events_enabled: + number_of_days = slack_settings.special_events_post_days or 30 + events: List[EventInstance] = DbManager.find_records( + cls=EventInstance, + filters=[ + (EventInstance.org_id == region.id or EventInstance.org.has(Org.parent_id == region.id)), + EventInstance.start_date >= current_date_cst(), + EventInstance.start_date <= current_date_cst() + timedelta(days=number_of_days), + EventInstance.is_active, + EventInstance.highlight, + ], + joinedloads=[EventInstance.org], + ) + if events and slack_settings.special_events_channel: + blocks = create_special_events_blocks(events, slack_settings) + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + WebClient(token=slack_settings.bot_token, ssl=ssl_context).chat_postMessage( + channel=slack_settings.special_events_channel, + text=f"Upcoming events for {region.name}:", + blocks=blocks, + ) + + +if __name__ == "__main__": + update_special_events() diff --git a/apps/slackbot/tests/application/series/test_service.py b/apps/slackbot/tests/application/series/test_service.py new file mode 100644 index 00000000..5860326f --- /dev/null +++ b/apps/slackbot/tests/application/series/test_service.py @@ -0,0 +1,198 @@ +import os +import sys +import unittest +from unittest.mock import MagicMock + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +from application.series import SeriesData +from application.series.service import SeriesService + + +def _make_series( + id: int = 1, + name: str = "Test Series", + org_id: int = 10, + region_id: int = 5, + start_date: str = "2025-01-06", + day_of_week: str = "monday", + event_type_ids: list = None, +) -> SeriesData: + return SeriesData( + id=id, + name=name, + org_id=org_id, + region_id=region_id, + start_date=start_date, + day_of_week=day_of_week, + start_time="0530", + end_time="0615", + event_type_ids=event_type_ids or [], + ) + + +class SeriesServiceTest(unittest.TestCase): + def _mock_repo(self): + return MagicMock() + + # ------------------------------------------------------------------ + # get_region_series + # ------------------------------------------------------------------ + + def test_get_region_series_no_ao_filter(self): + repo = self._mock_repo() + repo.get_by_region.return_value = [_make_series(id=1), _make_series(id=2)] + + service = SeriesService(repository=repo) + result = service.get_region_series("5") + + repo.get_by_region.assert_called_once_with(region_id=5, ao_id=None) + self.assertEqual(len(result), 2) + + def test_get_region_series_with_ao_filter(self): + repo = self._mock_repo() + repo.get_by_region.return_value = [_make_series(id=3)] + + service = SeriesService(repository=repo) + result = service.get_region_series(5, ao_id="10") + + repo.get_by_region.assert_called_once_with(region_id=5, ao_id=10) + self.assertEqual(result[0].id, 3) + + def test_get_region_series_coerces_str_ids(self): + repo = self._mock_repo() + repo.get_by_region.return_value = [] + service = SeriesService(repository=repo) + service.get_region_series("42") + repo.get_by_region.assert_called_once_with(region_id=42, ao_id=None) + + # ------------------------------------------------------------------ + # get_by_id + # ------------------------------------------------------------------ + + def test_get_by_id_returns_series(self): + repo = self._mock_repo() + repo.get_by_id.return_value = _make_series(id=7) + + service = SeriesService(repository=repo) + result = service.get_by_id("7") + + repo.get_by_id.assert_called_once_with(7) + self.assertIsNotNone(result) + self.assertEqual(result.id, 7) + + def test_get_by_id_returns_none_when_not_found(self): + repo = self._mock_repo() + repo.get_by_id.return_value = None + + service = SeriesService(repository=repo) + result = service.get_by_id(999) + self.assertIsNone(result) + + # ------------------------------------------------------------------ + # create_series + # ------------------------------------------------------------------ + + def test_create_series_delegates_to_repo(self): + repo = self._mock_repo() + repo.create.return_value = _make_series(id=99) + + service = SeriesService(repository=repo) + result = service.create_series( + region_id="5", + ao_id="10", + name="New Series", + start_date="2025-01-06", + start_time="0530", + end_time="0615", + day_of_week="monday", + ) + + repo.create.assert_called_once() + call_kwargs = repo.create.call_args.kwargs + self.assertEqual(call_kwargs["region_id"], 5) + self.assertEqual(call_kwargs["ao_id"], 10) + self.assertEqual(call_kwargs["name"], "New Series") + self.assertEqual(call_kwargs["day_of_week"], "monday") + self.assertEqual(result.id, 99) + + def test_create_series_coerces_location_id(self): + repo = self._mock_repo() + repo.create.return_value = _make_series() + + service = SeriesService(repository=repo) + service.create_series( + region_id=5, + ao_id=10, + name="Test", + start_date="2025-01-06", + start_time="0530", + end_time="0615", + day_of_week="monday", + location_id="42", + ) + call_kwargs = repo.create.call_args.kwargs + self.assertEqual(call_kwargs["location_id"], 42) + + def test_create_series_none_location_id_stays_none(self): + repo = self._mock_repo() + repo.create.return_value = _make_series() + + service = SeriesService(repository=repo) + service.create_series( + region_id=5, + ao_id=10, + name="Test", + start_date="2025-01-06", + start_time="0530", + end_time="0615", + day_of_week="monday", + location_id=None, + ) + call_kwargs = repo.create.call_args.kwargs + self.assertIsNone(call_kwargs["location_id"]) + + # ------------------------------------------------------------------ + # update_series + # ------------------------------------------------------------------ + + def test_update_series_delegates_to_repo(self): + repo = self._mock_repo() + repo.update.return_value = _make_series(id=1) + + service = SeriesService(repository=repo) + service.update_series( + series_id="1", + region_id="5", + ao_id="10", + name="Updated", + start_date="2025-01-06", + start_time="0600", + end_time="0645", + ) + + call_kwargs = repo.update.call_args.kwargs + self.assertEqual(call_kwargs["series_id"], 1) + self.assertEqual(call_kwargs["region_id"], 5) + self.assertEqual(call_kwargs["ao_id"], 10) + self.assertEqual(call_kwargs["name"], "Updated") + + # ------------------------------------------------------------------ + # delete_series + # ------------------------------------------------------------------ + + def test_delete_series_delegates_to_repo(self): + repo = self._mock_repo() + service = SeriesService(repository=repo) + service.delete_series("7") + repo.delete.assert_called_once_with(7) + + def test_delete_series_coerces_str_id(self): + repo = self._mock_repo() + service = SeriesService(repository=repo) + service.delete_series("42") + repo.delete.assert_called_once_with(42) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/features/calendar/test_ao.py b/apps/slackbot/tests/features/calendar/test_ao.py new file mode 100644 index 00000000..7bdf8734 --- /dev/null +++ b/apps/slackbot/tests/features/calendar/test_ao.py @@ -0,0 +1,359 @@ +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +from application.ao import AoData +from application.ao.service import AoService +from application.location import LocationData +from features.calendar.ao import ( + AoViews, + _build_ao_service, + _build_location_service, + handle_ao_add, + handle_ao_edit_delete, + manage_aos, +) +from utilities.slack import actions + + +def _make_ao( + id: int = 1, + name: str = "The Grind", + parent_id: int = 10, + description: str = None, + default_location_id: int = None, + logo_url: str = None, + meta: dict = None, +) -> AoData: + return AoData( + id=id, + name=name, + parent_id=parent_id, + description=description, + default_location_id=default_location_id, + logo_url=logo_url, + meta=meta or {}, + ) + + +def _make_location(id: int = 1, name: str = "City Park") -> LocationData: + return LocationData(id=id, name=name) + + +# --------------------------------------------------------------------------- +# Service tests +# --------------------------------------------------------------------------- + + +class AoServiceTest(unittest.TestCase): + def _mock_repo(self): + return MagicMock() + + def test_get_region_aos(self): + repo = self._mock_repo() + repo.get_by_parent_org.return_value = [_make_ao()] + service = AoService(repository=repo) + result = service.get_region_aos("10") + repo.get_by_parent_org.assert_called_once_with(10) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].name, "The Grind") + + def test_get_ao_by_id(self): + repo = self._mock_repo() + repo.get_by_id.return_value = _make_ao(id=7) + service = AoService(repository=repo) + result = service.get_ao_by_id(7) + repo.get_by_id.assert_called_once_with(7) + self.assertEqual(result.id, 7) + + def test_create_ao_coerces_types(self): + repo = self._mock_repo() + repo.create.return_value = _make_ao(id=99) + service = AoService(repository=repo) + service.create_ao( + parent_id="10", + name="New AO", + description=None, + slack_channel_id="C123", + default_location_id="5", + ) + repo.create.assert_called_once_with( + parent_id=10, + name="New AO", + description=None, + slack_channel_id="C123", + default_location_id=5, + ) + + def test_create_ao_with_none_location_stays_none(self): + repo = self._mock_repo() + repo.create.return_value = _make_ao() + service = AoService(repository=repo) + service.create_ao("10", "AO", None, None, None) + _, kwargs = repo.create.call_args + self.assertIsNone(kwargs["default_location_id"]) + + def test_update_ao_coerces_types(self): + repo = self._mock_repo() + service = AoService(repository=repo) + service.update_ao(1, "10", "Updated", None, "C999", "3", logo_url="http://x.com/logo.png") + repo.update.assert_called_once_with( + ao_id=1, + parent_id=10, + name="Updated", + description=None, + slack_channel_id="C999", + default_location_id=3, + logo_url="http://x.com/logo.png", + ) + + def test_delete_ao(self): + repo = self._mock_repo() + service = AoService(repository=repo) + service.delete_ao(7) + repo.delete.assert_called_once_with(7) + + +# --------------------------------------------------------------------------- +# Views tests +# --------------------------------------------------------------------------- + + +class AoViewsTest(unittest.TestCase): + def test_build_add_ao_modal_sets_location_options(self): + locations = [_make_location(id=1, name="City Park"), _make_location(id=2, name="River Trail")] + form = AoViews.build_add_ao_modal(locations) + location_block = form.get_block(actions.CALENDAR_ADD_AO_LOCATION) + self.assertIsNotNone(location_block) + self.assertEqual(len(location_block.element.options), 2) + self.assertEqual(location_block.element.options[0].value, "1") + self.assertEqual(location_block.element.options[1].value, "2") + + def test_build_add_ao_modal_has_five_blocks(self): + form = AoViews.build_add_ao_modal([]) + self.assertEqual(len(form.blocks), 5) + + def test_build_edit_ao_modal_sets_initial_name(self): + ao = _make_ao(name="The Ridgeline", meta={"slack_channel_id": "C123"}) + form = AoViews.build_edit_ao_modal(ao, []) + name_block = form.get_block(actions.CALENDAR_ADD_AO_NAME) + self.assertIsNotNone(name_block) + self.assertEqual(name_block.element.initial_value, "The Ridgeline") + + def test_build_edit_ao_modal_sets_channel(self): + ao = _make_ao(meta={"slack_channel_id": "C456"}) + form = AoViews.build_edit_ao_modal(ao, []) + channel_block = form.get_block(actions.CALENDAR_ADD_AO_CHANNEL) + self.assertIsNotNone(channel_block) + self.assertEqual(channel_block.element.initial_channel, "C456") + + def test_build_edit_ao_modal_sets_location(self): + ao = _make_ao(default_location_id=3) + locations = [_make_location(id=3, name="The Park")] + form = AoViews.build_edit_ao_modal(ao, locations) + location_block = form.get_block(actions.CALENDAR_ADD_AO_LOCATION) + self.assertIsNotNone(location_block) + self.assertIsNotNone(location_block.element.initial_option) + + def test_build_ao_list_modal_has_one_block_per_ao(self): + aos = [_make_ao(id=1), _make_ao(id=2), _make_ao(id=3)] + form = AoViews.build_ao_list_modal(aos) + self.assertEqual(len(form.blocks), 3) + self.assertEqual(form.blocks[0].block_id, f"{actions.AO_EDIT_DELETE}_1") + + def test_build_ao_list_modal_empty(self): + form = AoViews.build_ao_list_modal([]) + self.assertEqual(len(form.blocks), 1) + self.assertEqual(form.blocks[0].block_id, "ao-notice") + + +# --------------------------------------------------------------------------- +# Handler tests +# --------------------------------------------------------------------------- + + +class ManageAosTest(unittest.TestCase): + @patch("features.calendar.ao.add_loading_form") + @patch("features.calendar.ao.AoViews") + @patch("features.calendar.ao._build_location_service") + def test_manage_aos_add(self, mock_loc_svc, mock_views, mock_loading_form): + body = {"actions": [{"selected_option": {"value": "add"}}], "trigger_id": "t1"} + client = MagicMock() + region_record = MagicMock() + region_record.org_id = 10 + + mock_loading_form.return_value = "view_id_123" + mock_loc_svc.return_value.get_org_locations.return_value = [_make_location()] + mock_modal = MagicMock() + mock_views.build_add_ao_modal.return_value = mock_modal + + manage_aos(body, client, MagicMock(), {}, region_record) + + mock_loc_svc.return_value.get_org_locations.assert_called_once_with(10) + mock_modal.update_modal.assert_called_once() + + @patch("features.calendar.ao.AoViews") + @patch("features.calendar.ao._build_ao_service") + def test_manage_aos_edit(self, mock_svc, mock_views): + body = {"actions": [{"selected_option": {"value": "edit"}}], "trigger_id": "t1"} + client = MagicMock() + region_record = MagicMock() + region_record.org_id = 10 + + mock_svc.return_value.get_region_aos.return_value = [_make_ao()] + mock_modal = MagicMock() + mock_views.build_ao_list_modal.return_value = mock_modal + + manage_aos(body, client, MagicMock(), {}, region_record) + + mock_svc.return_value.get_region_aos.assert_called_once_with(10) + mock_modal.post_modal.assert_called_once() + + +class HandleAoAddTest(unittest.TestCase): + @patch("features.calendar.ao._build_ao_service") + @patch("features.calendar.ao.trigger_map_revalidation") + def test_handle_ao_add_creates_new(self, mock_trigger, mock_svc): + mock_service = MagicMock() + mock_svc.return_value = mock_service + mock_service.create_ao.return_value = _make_ao(id=99) + + body = { + "view": { + "state": { + "values": { + actions.CALENDAR_ADD_AO_NAME: { + actions.CALENDAR_ADD_AO_NAME: {"type": "plain_text_input", "value": "New AO"} + }, + actions.CALENDAR_ADD_AO_DESCRIPTION: { + actions.CALENDAR_ADD_AO_DESCRIPTION: {"type": "plain_text_input", "value": None} + }, + actions.CALENDAR_ADD_AO_CHANNEL: { + actions.CALENDAR_ADD_AO_CHANNEL: {"type": "channels_select", "selected_channel": "C123"} + }, + actions.CALENDAR_ADD_AO_LOCATION: { + actions.CALENDAR_ADD_AO_LOCATION: {"type": "static_select", "selected_option": None} + }, + actions.CALENDAR_ADD_AO_LOGO: { + actions.CALENDAR_ADD_AO_LOGO: {"type": "file_input", "files": None} + }, + } + }, + "private_metadata": "{}", + } + } + region_record = MagicMock() + region_record.org_id = 10 + + handle_ao_add(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.create_ao.assert_called_once() + mock_service.update_ao.assert_not_called() + mock_trigger.assert_called_once() + + @patch("features.calendar.ao._build_ao_service") + @patch("features.calendar.ao.trigger_map_revalidation") + def test_handle_ao_add_updates_existing(self, mock_trigger, mock_svc): + mock_service = MagicMock() + mock_svc.return_value = mock_service + + body = { + "view": { + "state": { + "values": { + actions.CALENDAR_ADD_AO_NAME: { + actions.CALENDAR_ADD_AO_NAME: {"type": "plain_text_input", "value": "Updated AO"} + }, + actions.CALENDAR_ADD_AO_DESCRIPTION: { + actions.CALENDAR_ADD_AO_DESCRIPTION: {"type": "plain_text_input", "value": None} + }, + actions.CALENDAR_ADD_AO_CHANNEL: { + actions.CALENDAR_ADD_AO_CHANNEL: {"type": "channels_select", "selected_channel": None} + }, + actions.CALENDAR_ADD_AO_LOCATION: { + actions.CALENDAR_ADD_AO_LOCATION: {"type": "static_select", "selected_option": None} + }, + actions.CALENDAR_ADD_AO_LOGO: { + actions.CALENDAR_ADD_AO_LOGO: {"type": "file_input", "files": None} + }, + } + }, + "private_metadata": '{"ao_id": 7}', + } + } + region_record = MagicMock() + region_record.org_id = 10 + + handle_ao_add(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.update_ao.assert_called_once() + mock_service.create_ao.assert_not_called() + call_kwargs = mock_service.update_ao.call_args[1] + self.assertEqual(call_kwargs["ao_id"], 7) + mock_trigger.assert_called_once() + + +class HandleAoEditDeleteTest(unittest.TestCase): + @patch("features.calendar.ao.build_ao_add_form") + @patch("features.calendar.ao._build_ao_service") + def test_edit_action_calls_build_form(self, mock_svc, mock_build_form): + mock_service = MagicMock() + mock_svc.return_value = mock_service + ao = _make_ao(id=5) + mock_service.get_ao_by_id.return_value = ao + + body = {"actions": [{"action_id": f"{actions.AO_EDIT_DELETE}_5", "selected_option": {"value": "Edit"}}]} + region_record = MagicMock() + + handle_ao_edit_delete(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.get_ao_by_id.assert_called_once_with(5) + mock_build_form.assert_called_once() + + @patch("features.calendar.ao.trigger_map_revalidation") + @patch("features.calendar.ao._build_ao_service") + def test_delete_action_calls_delete(self, mock_svc, mock_trigger): + mock_service = MagicMock() + mock_svc.return_value = mock_service + + body = {"actions": [{"action_id": f"{actions.AO_EDIT_DELETE}_5", "selected_option": {"value": "Delete"}}]} + region_record = MagicMock() + + handle_ao_edit_delete(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.delete_ao.assert_called_once_with(5) + mock_trigger.assert_called_once() + + @patch("features.calendar.ao._build_ao_service") + def test_edit_noop_when_ao_not_found(self, mock_svc): + mock_service = MagicMock() + mock_svc.return_value = mock_service + mock_service.get_ao_by_id.return_value = None + + body = {"actions": [{"action_id": f"{actions.AO_EDIT_DELETE}_99", "selected_option": {"value": "Edit"}}]} + with patch("features.calendar.ao.build_ao_add_form") as mock_build: + handle_ao_edit_delete(body, MagicMock(), MagicMock(), {}, MagicMock()) + mock_build.assert_not_called() + + +class CompositionRootTest(unittest.TestCase): + @patch("features.calendar.ao.get_api_ao_repository") + @patch("features.calendar.ao.AoService") + def test_build_ao_service_uses_api_repository(self, mock_svc_cls, mock_get_repo): + result = _build_ao_service() # noqa + mock_get_repo.assert_called_once_with() + mock_svc_cls.assert_called_once_with(repository=mock_get_repo.return_value) + + @patch("features.calendar.ao.get_api_location_repository") + @patch("features.calendar.ao.LocationService") + def test_build_location_service_uses_api_repository(self, mock_svc_cls, mock_get_repo): + result = _build_location_service() # noqa + mock_get_repo.assert_called_once_with() + mock_svc_cls.assert_called_once_with(repository=mock_get_repo.return_value) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/features/calendar/test_event_instance.py b/apps/slackbot/tests/features/calendar/test_event_instance.py new file mode 100644 index 00000000..184bcf1d --- /dev/null +++ b/apps/slackbot/tests/features/calendar/test_event_instance.py @@ -0,0 +1,628 @@ +import os +import sys +import unittest +from datetime import date +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +from application.event_instance import EventInstanceData +from application.event_instance.service import EventInstanceService +from features.calendar.event_instance import ( + _build_event_instance_service, + build_event_instance_list_form, + handle_event_instance_close, + handle_event_instance_edit_delete, + manage_event_instances, +) +from infrastructure.api_client.event_instance_repository import ( + ApiEventInstanceRepository, + get_api_event_instance_repository, +) +from infrastructure.api_client.exceptions import F3ApiNotFoundError + + +def _make_instance( + id: int = 1, + name: str = "The Grind", + org_id: int = 10, + start_date: date = date(2026, 6, 1), + start_time: str = "0600", + end_time: str = "0700", + series_exception: str | None = None, + event_type_ids: list[int] | None = None, + event_tag_ids: list[int] | None = None, + meta: dict | None = None, + is_private: bool = False, + highlight: bool = False, +) -> EventInstanceData: + return EventInstanceData( + id=id, + name=name, + org_id=org_id, + start_date=start_date, + start_time=start_time, + end_time=end_time, + series_exception=series_exception, + event_type_ids=event_type_ids or [5], + event_tag_ids=event_tag_ids or [], + meta=meta, + is_private=is_private, + highlight=highlight, + ) + + +# --------------------------------------------------------------------------- +# EventInstanceService tests +# --------------------------------------------------------------------------- + + +class EventInstanceServiceTest(unittest.TestCase): + def _mock_repo(self): + return MagicMock() + + def test_get_region_instances_delegates_and_sorts(self): + repo = self._mock_repo() + repo.get_list.return_value = [ + _make_instance(id=2, name="Z Event", start_date=date(2026, 6, 2)), + _make_instance(id=1, name="A Event", start_date=date(2026, 6, 1)), + ] + service = EventInstanceService(repository=repo) + result = service.get_region_instances(region_org_id="10", start_date=date(2026, 6, 1)) + + repo.get_list.assert_called_once_with(region_org_id=10, start_date=date(2026, 6, 1), ao_org_id=None) + self.assertEqual(result[0].id, 1) # A Event comes first after sort + self.assertEqual(result[1].id, 2) + + def test_get_region_instances_passes_ao_filter(self): + repo = self._mock_repo() + repo.get_list.return_value = [] + service = EventInstanceService(repository=repo) + service.get_region_instances(region_org_id=10, start_date=date(2026, 6, 1), ao_org_id="20") + + repo.get_list.assert_called_once_with(region_org_id=10, start_date=date(2026, 6, 1), ao_org_id=20) + + def test_get_region_instances_caps_at_limit(self): + repo = self._mock_repo() + repo.get_list.return_value = [_make_instance(id=i) for i in range(60)] + service = EventInstanceService(repository=repo) + result = service.get_region_instances(region_org_id=10, start_date=date(2026, 6, 1), limit=40) + self.assertEqual(len(result), 40) + + def test_get_by_id(self): + repo = self._mock_repo() + repo.get_by_id.return_value = _make_instance(id=7) + service = EventInstanceService(repository=repo) + result = service.get_by_id(7) + repo.get_by_id.assert_called_once_with(7) + self.assertEqual(result.id, 7) + + def test_create_instance_coerces_types(self): + repo = self._mock_repo() + repo.create.return_value = _make_instance(id=99) + service = EventInstanceService(repository=repo) + service.create_instance( + name="New Event", + org_id="10", + start_date=date(2026, 7, 4), + start_time="0600", + end_time="0700", + location_id="5", + event_type_ids=[1], + event_tag_ids=[2], + ) + _, kwargs = repo.create.call_args + self.assertEqual(kwargs["org_id"], 10) + self.assertEqual(kwargs["location_id"], 5) + + def test_create_instance_none_location_stays_none(self): + repo = self._mock_repo() + repo.create.return_value = _make_instance() + service = EventInstanceService(repository=repo) + service.create_instance( + name="Event", + org_id=10, + start_date=date(2026, 7, 4), + start_time="0600", + end_time="0700", + location_id=None, + ) + _, kwargs = repo.create.call_args + self.assertIsNone(kwargs["location_id"]) + + def test_update_instance(self): + repo = self._mock_repo() + repo.update.return_value = _make_instance(id=5) + service = EventInstanceService(repository=repo) + service.update_instance( + instance_id=5, + name="Updated", + org_id=10, + start_date=date(2026, 7, 4), + start_time="0600", + end_time="0700", + ) + _, kwargs = repo.update.call_args + self.assertEqual(kwargs["instance_id"], 5) + self.assertEqual(kwargs["name"], "Updated") + + def test_close_instance_fetches_meta_and_closes(self): + repo = self._mock_repo() + existing = _make_instance(id=3, meta={"existing_key": "val"}) + repo.get_by_id.return_value = existing + service = EventInstanceService(repository=repo) + service.close_instance(instance_id=3, close_reason="Weather") + + repo.get_by_id.assert_called_once_with(3) + _, kwargs = repo.close.call_args + self.assertEqual(kwargs["instance"], existing) + self.assertEqual(kwargs["meta"]["series_exception_reason"], "Weather") + self.assertEqual(kwargs["meta"]["existing_key"], "val") # preserves existing meta + + def test_close_instance_no_reason_omits_key(self): + repo = self._mock_repo() + existing = _make_instance(id=3, meta={}) + repo.get_by_id.return_value = existing + service = EventInstanceService(repository=repo) + service.close_instance(instance_id=3, close_reason=None) + + _, kwargs = repo.close.call_args + self.assertEqual(kwargs["instance"], existing) + self.assertNotIn("series_exception_reason", kwargs["meta"]) + + def test_reopen_instance(self): + repo = self._mock_repo() + existing = _make_instance(id=7) + repo.get_by_id.return_value = existing + service = EventInstanceService(repository=repo) + service.reopen_instance(7) + repo.get_by_id.assert_called_once_with(7) + repo.reopen.assert_called_once_with(instance=existing) + + def test_close_instance_raises_when_instance_missing(self): + repo = self._mock_repo() + repo.get_by_id.return_value = None + service = EventInstanceService(repository=repo) + + with self.assertRaisesRegex(ValueError, "Event instance 3 was not found"): + service.close_instance(instance_id=3, close_reason="Weather") + + repo.close.assert_not_called() + + def test_delete_instance(self): + repo = self._mock_repo() + service = EventInstanceService(repository=repo) + service.delete_instance(9) + repo.delete.assert_called_once_with(9) + + +# --------------------------------------------------------------------------- +# ApiEventInstanceRepository tests +# --------------------------------------------------------------------------- + + +class ApiEventInstanceRepositoryTest(unittest.TestCase): + def setUp(self): + self.client = MagicMock() + self.repo = ApiEventInstanceRepository(self.client) + + def _raw_instance(self, id: int = 1, series_exception=None): + return { + "id": id, + "name": "The Grind", + "orgId": 10, + "startDate": "2026-06-01", + "startTime": "0600", + "endTime": "0700", + "isActive": True, + "isPrivate": False, + "highlight": False, + "eventTypes": [{"eventTypeId": 5}], + "eventTags": [], + "seriesException": series_exception, + } + + def test_get_list_builds_correct_params(self): + self.client.get.return_value = {"eventInstances": [self._raw_instance()]} + result = self.repo.get_list(region_org_id=10, start_date=date(2026, 6, 1)) + + self.client.get.assert_called_once_with( + "/v1/event-instance", + params={"regionOrgId": 10, "startDate": "2026-06-01"}, + ) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 1) + + def test_get_list_with_ao_filter(self): + self.client.get.return_value = {"eventInstances": []} + self.repo.get_list(region_org_id=10, start_date=date(2026, 6, 1), ao_org_id=20) + + _, kwargs = self.client.get.call_args + self.assertIn("aoOrgId", kwargs["params"]) + self.assertEqual(kwargs["params"]["aoOrgId"], 20) + + def test_get_list_handles_results_fallback(self): + self.client.get.return_value = {"results": [self._raw_instance(id=2)]} + result = self.repo.get_list(region_org_id=10, start_date=date(2026, 6, 1)) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 2) + + def test_get_list_empty_on_unexpected_key(self): + self.client.get.return_value = {"unexpected": []} + result = self.repo.get_list(region_org_id=10, start_date=date(2026, 6, 1)) + self.assertEqual(result, []) + + def test_get_by_id_parses_payload(self): + self.client.get.return_value = {"eventInstance": self._raw_instance(id=5)} + result = self.repo.get_by_id(5) + + self.client.get.assert_called_once_with("/v1/event-instance/id/5") + self.assertIsNotNone(result) + self.assertEqual(result.id, 5) + self.assertEqual(result.start_date, date(2026, 6, 1)) + self.assertEqual(result.event_type_ids, [5]) + + def test_get_by_id_returns_none_on_not_found(self): + self.client.get.side_effect = F3ApiNotFoundError(404, "not found") + result = self.repo.get_by_id(999) + self.assertIsNone(result) + + def test_get_by_id_supports_result_fallback(self): + self.client.get.return_value = {"result": self._raw_instance(id=7)} + result = self.repo.get_by_id(7) + self.assertEqual(result.id, 7) + + def test_parse_instance_handles_snake_case_fields(self): + """Raw payload uses snake_case field names (older API format).""" + raw = { + "id": 1, + "name": "Snake", + "org_id": 10, + "start_date": "2026-07-01", + "start_time": "0530", + "end_time": "0630", + "is_active": True, + "is_private": False, + "highlight": False, + "event_types": [], + "event_tags": [], + } + self.client.get.return_value = {"eventInstance": raw} + result = self.repo.get_by_id(1) + self.assertEqual(result.start_time, "0530") + self.assertEqual(result.org_id, 10) + + def test_parse_instance_handles_singular_event_type_id(self): + """API returns singular eventTypeId / eventTagId (not arrays).""" + raw = { + "id": 1, + "name": "Singular", + "orgId": 10, + "startDate": "2026-07-01", + "startTime": "0530", + "endTime": "0630", + "isActive": True, + "isPrivate": False, + "highlight": False, + "eventTypeId": 3, + "eventTagId": 7, + } + self.client.get.return_value = {"eventInstance": raw} + result = self.repo.get_by_id(1) + self.assertEqual(result.event_type_ids, [3]) + self.assertEqual(result.event_tag_ids, [7]) + + def test_create_posts_without_id(self): + self.client.post.return_value = {"eventInstance": self._raw_instance(id=99)} + result = self.repo.create( + name="New", + org_id=10, + start_date=date(2026, 7, 4), + start_time="0600", + end_time="0700", + description=None, + location_id=None, + event_type_ids=[1], + event_tag_ids=[], + is_active=True, + is_private=False, + meta=None, + highlight=False, + preblast_rich=None, + preblast=None, + ) + _, kwargs = self.client.post.call_args + self.assertEqual(kwargs["json"]["name"], "New") + self.assertNotIn("id", kwargs["json"]) + # API expects singular eventTypeId / eventTagId (not arrays) + self.assertEqual(kwargs["json"]["eventTypeId"], 1) + self.assertNotIn("eventTagId", kwargs["json"]) # no tag selected + self.assertEqual(result.id, 99) + + def test_update_posts_with_id(self): + self.client.post.return_value = {"eventInstance": self._raw_instance(id=5)} + self.repo.update( + instance_id=5, + name="Updated", + org_id=10, + start_date=date(2026, 7, 4), + start_time="0600", + end_time="0700", + description=None, + location_id=None, + event_type_ids=[1], + event_tag_ids=[], + is_active=True, + is_private=False, + meta=None, + highlight=False, + preblast_rich=None, + preblast=None, + ) + _, kwargs = self.client.post.call_args + self.assertEqual(kwargs["json"]["id"], 5) + self.assertEqual(kwargs["json"]["name"], "Updated") + self.assertEqual(kwargs["json"]["eventTypeId"], 1) + self.assertNotIn("eventTagId", kwargs["json"]) # empty list → omitted + + def test_close_posts_correct_payload(self): + instance = _make_instance(id=3, meta={"existing_key": "val"}) + self.repo.close(instance=instance, meta={"series_exception_reason": "Rain", "existing_key": "val"}) + self.client.post.assert_called_once_with( + "/v1/event-instance", + json={ + "id": 3, + "name": "The Grind", + "orgId": 10, + "startDate": "2026-06-01", + "startTime": "0600", + "endTime": "0700", + "isActive": True, + "isPrivate": False, + "highlight": False, + "eventTypeId": 5, + "meta": {"series_exception_reason": "Rain", "existing_key": "val"}, + "seriesException": "closed", + }, + ) + + def test_reopen_posts_correct_payload(self): + instance = _make_instance(id=4, series_exception="closed", meta={"existing_key": "val"}) + self.repo.reopen(instance=instance) + self.client.post.assert_called_once_with( + "/v1/event-instance", + json={ + "id": 4, + "name": "The Grind", + "orgId": 10, + "startDate": "2026-06-01", + "startTime": "0600", + "endTime": "0700", + "isActive": True, + "isPrivate": False, + "highlight": False, + "eventTypeId": 5, + "meta": {"existing_key": "val"}, + "seriesException": None, + }, + ) + + def test_close_raises_when_existing_instance_is_missing_required_fields(self): + instance = _make_instance(id=8) + instance.org_id = 0 + + with self.assertRaisesRegex(ValueError, "missing required field 'org_id'"): + self.repo.close(instance=instance, meta={}) + + self.client.post.assert_not_called() + + def test_delete_calls_correct_endpoint(self): + self.repo.delete(instance_id=6) + self.client.delete.assert_called_once_with("/v1/event-instance/id/6") + + @patch("infrastructure.api_client.event_instance_repository.get_f3_api_client") + def test_singleton_returns_same_instance(self, mock_get_client): + mock_get_client.return_value = MagicMock() + with patch("infrastructure.api_client.event_instance_repository._repo", None): + repo1 = get_api_event_instance_repository() + repo2 = get_api_event_instance_repository() + self.assertIs(repo1, repo2) + + +# --------------------------------------------------------------------------- +# Composition root test +# --------------------------------------------------------------------------- + + +class CompositionRootTest(unittest.TestCase): + @patch("features.calendar.event_instance.get_api_event_instance_repository") + @patch("features.calendar.event_instance.EventInstanceService") + def test_build_event_instance_service_uses_api_repository(self, mock_svc_cls, mock_get_repo): + result = _build_event_instance_service() + mock_get_repo.assert_called_once() + mock_svc_cls.assert_called_once_with(repository=mock_get_repo.return_value) + self.assertIs(result, mock_svc_cls.return_value) + + +# --------------------------------------------------------------------------- +# Handler tests +# --------------------------------------------------------------------------- + + +class ManageEventInstancesTest(unittest.TestCase): + def _region_record(self): + r = MagicMock() + r.org_id = 10 + return r + + @patch("features.calendar.event_instance.build_event_instance_add_form") + def test_add_action_calls_add_form(self, mock_add_form): + body = {"actions": [{"selected_option": {"value": "add"}}]} + manage_event_instances(body, MagicMock(), MagicMock(), {}, self._region_record()) + mock_add_form.assert_called_once() + + @patch("features.calendar.event_instance.build_event_instance_list_form") + def test_edit_action_calls_list_form(self, mock_list_form): + body = {"actions": [{"selected_option": {"value": "edit"}}]} + manage_event_instances(body, MagicMock(), MagicMock(), {}, self._region_record()) + mock_list_form.assert_called_once() + + +class HandleEventInstanceEditDeleteTest(unittest.TestCase): + def _region_record(self): + r = MagicMock() + r.org_id = 10 + return r + + @patch("features.calendar.event_instance.build_event_instance_add_form") + @patch("features.calendar.event_instance._build_event_instance_service") + def test_edit_fetches_instance_and_opens_form(self, mock_build_service, mock_add_form): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + mock_service.get_by_id.return_value = _make_instance(id=3) + + body = { + "actions": [{"action_id": "event-instance-edit-delete_3", "selected_option": {"value": "Edit"}}], + "view": {"id": "V1"}, + } + handle_event_instance_edit_delete(body, MagicMock(), MagicMock(), {}, self._region_record()) + + mock_service.get_by_id.assert_called_once_with(3) + mock_add_form.assert_called_once() + + @patch("features.calendar.event_instance.build_event_instance_list_form") + @patch("features.calendar.event_instance._build_event_instance_service") + def test_reopen_calls_reopen_then_refreshes_list(self, mock_build_service, mock_list_form): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + body = { + "actions": [{"action_id": "event-instance-edit-delete_7", "selected_option": {"value": "Reopen"}}], + "view": {"id": "V2"}, + } + handle_event_instance_edit_delete(body, MagicMock(), MagicMock(), {}, self._region_record()) + + mock_service.reopen_instance.assert_called_once_with(7) + mock_list_form.assert_called_once() + + @patch("features.calendar.event_instance.build_event_instance_list_form") + @patch("features.calendar.event_instance._build_event_instance_service") + def test_delete_calls_delete_then_refreshes_list(self, mock_build_service, mock_list_form): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + body = { + "actions": [{"action_id": "event-instance-edit-delete_5", "selected_option": {"value": "Delete"}}], + "view": {"id": "V3"}, + } + handle_event_instance_edit_delete(body, MagicMock(), MagicMock(), {}, self._region_record()) + + mock_service.delete_instance.assert_called_once_with(5) + mock_list_form.assert_called_once() + + @patch("features.calendar.event_instance._build_event_instance_service") + def test_close_opens_close_modal(self, mock_build_service): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + client = MagicMock() + + body = { + "actions": [{"action_id": "event-instance-edit-delete_4", "selected_option": {"value": "Close"}}], + "view": {"id": "V4"}, + } + handle_event_instance_edit_delete(body, client, MagicMock(), {}, self._region_record()) + + # Close action opens the close-reason modal — views_update IS called, close_instance is NOT + client.views_update.assert_called_once() + mock_service.close_instance.assert_not_called() + + +class HandleEventInstanceCloseTest(unittest.TestCase): + @patch("features.calendar.event_instance._build_event_instance_service") + def test_close_delegates_to_service(self, mock_build_service): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + import json + + body = { + "view": { + "private_metadata": json.dumps({"event_instance_id": 9}), + # orm.BlockView.get_selected_values needs 'blocks' to walk form fields + "blocks": [ + { + "block_id": "event_close_reason", + "element": {"action_id": "event_close_reason", "type": "plain_text_input"}, + } + ], + "state": { + "values": { + "event_close_reason": {"event_close_reason": {"type": "plain_text_input", "value": "Rain out"}} + } + }, + } + } + handle_event_instance_close(body, MagicMock(), MagicMock(), {}, MagicMock()) + + mock_service.close_instance.assert_called_once() + call_kwargs = mock_service.close_instance.call_args + self.assertEqual(call_kwargs.kwargs["instance_id"], 9) + + +class BuildEventInstanceListFormTest(unittest.TestCase): + def _region_record(self): + r = MagicMock() + r.org_id = 10 + return r + + @patch("features.calendar.event_instance._build_ao_service") + @patch("features.calendar.event_instance._build_event_instance_service") + @patch("features.calendar.event_instance.add_loading_form") + def test_list_form_empty_records_adds_notice(self, mock_loading, mock_build_service, mock_build_ao): + mock_loading.return_value = "V_LOAD" + mock_service = MagicMock() + mock_build_service.return_value = mock_service + mock_service.get_region_instances.return_value = [] + + mock_ao_service = MagicMock() + mock_build_ao.return_value = mock_ao_service + mock_ao_service.get_region_aos.return_value = [] + + client = MagicMock() + body = {"actions": [{"action_id": None}], "trigger_id": "T1"} + build_event_instance_list_form(body, client, MagicMock(), {}, self._region_record(), loading_form=True) + + client.views_update.assert_called_once() + # The modal payload blocks should include the empty notice + modal_payload = client.views_update.call_args.kwargs.get("view") or client.views_update.call_args[1].get("view") + self.assertIsNotNone(modal_payload) + + @patch("features.calendar.event_instance._build_ao_service") + @patch("features.calendar.event_instance._build_event_instance_service") + @patch("features.calendar.event_instance.add_loading_form") + def test_closed_event_label_marked(self, mock_loading, mock_build_service, mock_build_ao): + mock_loading.return_value = "V_LOAD" + mock_service = MagicMock() + mock_build_service.return_value = mock_service + closed_instance = _make_instance(id=1, name="Cancelled", series_exception="closed") + mock_service.get_region_instances.return_value = [closed_instance] + + mock_ao_service = MagicMock() + mock_build_ao.return_value = mock_ao_service + mock_ao_service.get_region_aos.return_value = [] + + client = MagicMock() + body = {"actions": [{"action_id": None}], "trigger_id": "T1"} + build_event_instance_list_form(body, client, MagicMock(), {}, self._region_record(), loading_form=True) + + call_kwargs = client.views_update.call_args[1] + view_payload = call_kwargs["view"] + # Find the section block for the closed event + blocks = view_payload["blocks"] + event_block = next((b for b in blocks if b.get("block_id", "").endswith("_1")), None) + self.assertIsNotNone(event_block) + self.assertIn("[CLOSED]", event_block.get("text", {}).get("text", "")) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/features/calendar/test_event_tag.py b/apps/slackbot/tests/features/calendar/test_event_tag.py new file mode 100644 index 00000000..934ef3d7 --- /dev/null +++ b/apps/slackbot/tests/features/calendar/test_event_tag.py @@ -0,0 +1,392 @@ +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +import unittest +from unittest.mock import MagicMock, patch + +from application.event_tag import EventTagData +from application.event_tag.service import EventTagService +from features.calendar.event_tag import ( + CALENDAR_ADD_EVENT_TAG_COLOR, + CALENDAR_ADD_EVENT_TAG_NEW, + CALENDAR_EVENT_TAG_COLORS_IN_USE, + EDIT_DELETE_AO_CALLBACK_ID, + EVENT_TAG_EDIT_DELETE, + EventTagViews, + _build_event_tag_service, + handle_event_tag_add, + handle_event_tag_edit_delete, + manage_event_tags, +) + + +def _make_tag(id: int = 1, name: str = "Tag1", color: str = "Red", org_id: int = 1) -> EventTagData: + return EventTagData(id=id, name=name, color=color, specific_org_id=org_id) + + +class EventTagServiceTest(unittest.TestCase): + def _mock_repo(self): + return MagicMock() + + def test_get_org_event_tags(self): + repo = self._mock_repo() + repo.get_by_org.return_value = [_make_tag()] + + service = EventTagService(repository=repo) + result = service.get_org_event_tags("1") + + repo.get_by_org.assert_called_once_with(1) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].name, "Tag1") + + def test_create_org_specific_tag(self): + repo = self._mock_repo() + service = EventTagService(repository=repo) + service.create_org_specific_tag("New Tag", "Green", "1") + + repo.create.assert_called_once_with("New Tag", "Green", 1) + + def test_get_event_tag_by_id(self): + repo = self._mock_repo() + repo.get_by_id.return_value = _make_tag(id=7) + + service = EventTagService(repository=repo) + result = service.get_event_tag_by_id(7) + + repo.get_by_id.assert_called_once_with(7) + self.assertEqual(result.id, 7) + + def test_update_org_specific_tag(self): + repo = self._mock_repo() + service = EventTagService(repository=repo) + service.update_org_specific_tag(1, "Updated Tag", "Yellow") + + repo.update.assert_called_once_with(1, "Updated Tag", "Yellow") + + def test_delete_org_specific_tag(self): + repo = self._mock_repo() + service = EventTagService(repository=repo) + service.delete_org_specific_tag(1) + + repo.delete.assert_called_once_with(1) + + +class EventTagViewsTest(unittest.TestCase): + def test_build_add_tag_modal(self): + org_tags = [_make_tag()] + + views = EventTagViews() + form = views.build_add_tag_modal(org_tags) + + self.assertEqual(len(form.blocks), 4) + colors_block = form.get_block(CALENDAR_EVENT_TAG_COLORS_IN_USE) + self.assertIsNotNone(colors_block) + self.assertIn("Colors already in use:", colors_block.text.text) + self.assertIn("Tag1 - Red", colors_block.text.text) + + def test_build_edit_tag_modal(self): + tag_to_edit = _make_tag() + org_tags = [_make_tag()] + + views = EventTagViews() + form = views.build_edit_tag_modal(tag_to_edit, org_tags) + + self.assertEqual(len(form.blocks), 4) + name_block = form.get_block(CALENDAR_ADD_EVENT_TAG_NEW) + self.assertIsNotNone(name_block) + self.assertEqual(name_block.label.text, "Edit Event Tag") + self.assertEqual(name_block.element.initial_value, "Tag1") + + def test_build_tag_list_modal(self): + org_tags = [_make_tag()] + + views = EventTagViews() + form = views.build_tag_list_modal(org_tags) + + self.assertEqual(len(form.blocks), 2) + self.assertEqual(form.blocks[0].block_id, f"{EVENT_TAG_EDIT_DELETE}_1") + self.assertEqual(form.blocks[0].accessory.action_id, f"{EVENT_TAG_EDIT_DELETE}_1") + + def test_build_tag_list_modal_with_notice(self): + org_tags = [_make_tag()] + + views = EventTagViews() + form = views.build_tag_list_modal(org_tags, notice_text="Tag missing") + + self.assertEqual(len(form.blocks), 3) + self.assertEqual(form.blocks[0].text.text, "Tag missing") + self.assertEqual(form.blocks[1].block_id, f"{EVENT_TAG_EDIT_DELETE}_1") + + +class EventTagHandlersTest(unittest.TestCase): + @patch("features.calendar.event_tag.add_loading_form") + @patch("features.calendar.event_tag.EventTagViews") + @patch("features.calendar.event_tag._build_event_tag_service") + def test_manage_event_tags_add(self, mock_build_service, mock_views, mock_add_loading_form): + body = {"actions": [{"selected_option": {"value": "add"}}], "trigger_id": "trigger123"} + client = MagicMock() + logger = MagicMock() + context = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + mock_add_loading_form.return_value = "view123" + mock_service.get_org_event_tags.return_value = [] + mock_modal = MagicMock() + mock_views.return_value.build_add_tag_modal.return_value = mock_modal + + manage_event_tags(body, client, logger, context, region_record) + + mock_service.get_org_event_tags.assert_called_once_with("org1") + mock_views.return_value.build_add_tag_modal.assert_called_once_with([]) + mock_modal.update_modal.assert_called_once() + + @patch("features.calendar.event_tag.EventTagViews") + @patch("features.calendar.event_tag._build_event_tag_service") + def test_manage_event_tags_edit(self, mock_build_service, mock_views): + body = {"actions": [{"selected_option": {"value": "edit"}}], "trigger_id": "trigger123"} + client = MagicMock() + logger = MagicMock() + context = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + mock_service.get_org_event_tags.return_value = [_make_tag()] + mock_modal = MagicMock() + mock_views.return_value.build_tag_list_modal.return_value = mock_modal + + manage_event_tags(body, client, logger, context, region_record) + + mock_service.get_org_event_tags.assert_called_once_with("org1") + mock_views.return_value.build_tag_list_modal.assert_called_once_with([_make_tag()]) + mock_modal.post_modal.assert_called_once() + + @patch("features.calendar.event_tag.EVENT_TAG_FORM") + @patch("features.calendar.event_tag._build_event_tag_service") + def test_handle_event_tag_add_new(self, mock_build_service, mock_form): + mock_form.get_selected_values.return_value = { + CALENDAR_ADD_EVENT_TAG_NEW: "New Tag", + CALENDAR_ADD_EVENT_TAG_COLOR: "Green", + } + body = {"view": {"private_metadata": "{}"}} + client = MagicMock() + logger = MagicMock() + context = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + handle_event_tag_add(body, client, logger, context, region_record) + + mock_service.create_org_specific_tag.assert_called_once_with("New Tag", "Green", "org1") + + @patch("features.calendar.event_tag.EVENT_TAG_FORM") + @patch("features.calendar.event_tag._build_event_tag_service") + def test_handle_event_tag_add_edit_existing(self, mock_build_service, mock_form): + mock_form.get_selected_values.return_value = { + CALENDAR_ADD_EVENT_TAG_NEW: "Updated Name", + CALENDAR_ADD_EVENT_TAG_COLOR: "Blue", + } + body = {"view": {"private_metadata": '{"edit_event_tag_id": 99}'}} + client = MagicMock() + logger = MagicMock() + context = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + handle_event_tag_add(body, client, logger, context, region_record) + + mock_service.update_org_specific_tag.assert_called_once_with(99, "Updated Name", "Blue") + mock_service.create_org_specific_tag.assert_not_called() + + @patch("features.calendar.event_tag.EVENT_TAG_FORM") + @patch("features.calendar.event_tag._build_event_tag_service") + def test_handle_event_tag_add_missing_values_noop(self, mock_build_service, mock_form): + mock_form.get_selected_values.return_value = { + CALENDAR_ADD_EVENT_TAG_NEW: "", + CALENDAR_ADD_EVENT_TAG_COLOR: "", + } + body = {"view": {"private_metadata": "{}"}} + client = MagicMock() + logger = MagicMock() + context = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + handle_event_tag_add(body, client, logger, context, region_record) + + mock_service.update_org_specific_tag.assert_not_called() + mock_service.create_org_specific_tag.assert_not_called() + + @patch("features.calendar.event_tag.add_loading_form") + @patch("features.calendar.event_tag.EventTagViews") + @patch("features.calendar.event_tag._build_event_tag_service") + def test_handle_event_tag_edit_delete_edit(self, mock_build_service, mock_views, mock_add_loading_form): + body = { + "actions": [{"action_id": f"{EVENT_TAG_EDIT_DELETE}_1", "selected_option": {"value": "Edit"}}], + "trigger_id": "trigger123", + } + client = MagicMock() + logger = MagicMock() + context = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + mock_add_loading_form.return_value = "view123" + mock_event_tag = _make_tag() + mock_service.get_org_event_tags.return_value = [mock_event_tag] + mock_modal = MagicMock() + mock_views.return_value.build_edit_tag_modal.return_value = mock_modal + + handle_event_tag_edit_delete(body, client, logger, context, region_record) + + mock_service.get_org_event_tags.assert_called_once_with("org1") + mock_views.return_value.build_edit_tag_modal.assert_called_once_with(mock_event_tag, [mock_event_tag]) + mock_modal.update_modal.assert_called_once() + + @patch("features.calendar.event_tag.add_loading_form") + @patch("features.calendar.event_tag.EventTagViews") + @patch("features.calendar.event_tag._build_event_tag_service") + def test_handle_event_tag_edit_delete_missing_tag_refreshes_list( + self, mock_build_service, mock_views, mock_add_loading_form + ): + body = { + "actions": [{"action_id": f"{EVENT_TAG_EDIT_DELETE}_1", "selected_option": {"value": "Edit"}}], + "trigger_id": "trigger123", + } + client = MagicMock() + logger = MagicMock() + context = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + mock_add_loading_form.return_value = "view123" + mock_service.get_org_event_tags.return_value = [_make_tag(id=2)] + mock_modal = MagicMock() + mock_views.return_value.build_tag_list_modal.return_value = mock_modal + + handle_event_tag_edit_delete(body, client, logger, context, region_record) + + mock_views.return_value.build_tag_list_modal.assert_called_once_with( + [_make_tag(id=2)], notice_text="The selected event tag no longer exists. The list has been refreshed." + ) + mock_views.return_value.build_edit_tag_modal.assert_not_called() + mock_modal.update_modal.assert_called_once() + self.assertEqual(mock_modal.update_modal.call_args.kwargs["callback_id"], EDIT_DELETE_AO_CALLBACK_ID) + + +class EventTagCompositionTest(unittest.TestCase): + @patch("features.calendar.event_tag.EventTagService") + @patch("features.calendar.event_tag.get_api_event_tag_repository") + def test_build_event_tag_service_uses_api_repository(self, mock_get_repo, mock_service_cls): + mock_repo = MagicMock() + mock_get_repo.return_value = mock_repo + expected_service = MagicMock() + mock_service_cls.return_value = expected_service + + result = _build_event_tag_service() + + mock_get_repo.assert_called_once_with() + mock_service_cls.assert_called_once_with(repository=mock_repo) + self.assertIs(result, expected_service) + + @patch("features.calendar.event_tag.add_loading_form") + @patch("features.calendar.event_tag.EventTagViews") + @patch("features.calendar.event_tag._build_event_tag_service") + def test_handle_event_tag_edit_delete_edit_invalid_id_noop( + self, mock_build_service, mock_views, mock_add_loading_form + ): + body = { + "actions": [{"action_id": f"{EVENT_TAG_EDIT_DELETE}_bad", "selected_option": {"value": "Edit"}}], + "trigger_id": "trigger123", + } + client = MagicMock() + logger = MagicMock() + context = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + mock_service.get_org_event_tags.return_value = [_make_tag()] + + handle_event_tag_edit_delete(body, client, logger, context, region_record) + + mock_build_service.assert_not_called() + mock_views.assert_not_called() + mock_add_loading_form.assert_not_called() + mock_service.get_org_event_tags.assert_not_called() + + @patch("features.calendar.event_tag.EventTagViews") + @patch("features.calendar.event_tag._build_event_tag_service") + def test_handle_event_tag_edit_delete_delete_invalid_id_noop(self, mock_build_service, mock_views): + body = { + "actions": [{"action_id": f"{EVENT_TAG_EDIT_DELETE}_bad", "selected_option": {"value": "Delete"}}], + "view": {"id": "view123"}, + } + client = MagicMock() + logger = MagicMock() + context = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + handle_event_tag_edit_delete(body, client, logger, context, region_record) + + mock_build_service.assert_not_called() + mock_views.assert_not_called() + mock_service.get_org_event_tags.assert_not_called() + mock_service.delete_org_specific_tag.assert_not_called() + + @patch("features.calendar.event_tag.EventTagViews") + @patch("features.calendar.event_tag._build_event_tag_service") + def test_handle_event_tag_edit_delete_delete(self, mock_build_service, mock_views): + body = { + "actions": [{"action_id": f"{EVENT_TAG_EDIT_DELETE}_1", "selected_option": {"value": "Delete"}}], + "view": {"id": "view123"}, + } + client = MagicMock() + logger = MagicMock() + context = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + mock_service.get_org_event_tags.return_value = [_make_tag(id=1, name="Tag1"), _make_tag(id=2, name="Tag2")] + mock_modal = MagicMock() + mock_views.return_value.build_tag_list_modal.return_value = mock_modal + + handle_event_tag_edit_delete(body, client, logger, context, region_record) + + mock_service.delete_org_specific_tag.assert_called_once_with(1) + mock_views.return_value.build_tag_list_modal.assert_called_once_with( + [_make_tag(id=2, name="Tag2")], + notice_text="The Tag1 tag has been deleted.", + ) + mock_modal.update_modal.assert_called_once() + self.assertEqual(mock_modal.update_modal.call_args.kwargs["callback_id"], EDIT_DELETE_AO_CALLBACK_ID) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/features/calendar/test_event_type.py b/apps/slackbot/tests/features/calendar/test_event_type.py new file mode 100644 index 00000000..ac2a0a2f --- /dev/null +++ b/apps/slackbot/tests/features/calendar/test_event_type.py @@ -0,0 +1,372 @@ +import os +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +import unittest +from unittest.mock import MagicMock, patch + +from application.event_type import EventTypeData +from application.event_type.service import EventTypeService +from features.calendar.event_type import ( + CALENDAR_ADD_EVENT_TYPE_ACRONYM, + CALENDAR_ADD_EVENT_TYPE_CATEGORY, + CALENDAR_ADD_EVENT_TYPE_LIST, + CALENDAR_ADD_EVENT_TYPE_NEW, + EVENT_TYPE_EDIT_DELETE, + EventTypeViews, + _build_event_type_service, + handle_event_type_add, + handle_event_type_edit_delete, + manage_event_types, +) + + +def _make_type( + id: int = 1, + name: str = "Bootcamp", + acronym: str = "BC", + event_category: str = "first_f", + org_id: int = 1, +) -> EventTypeData: + return EventTypeData(id=id, name=name, acronym=acronym, event_category=event_category, specific_org_id=org_id) + + +class EventTypeServiceTest(unittest.TestCase): + def _mock_repo(self): + return MagicMock() + + def test_get_org_specific_event_types(self): + repo = self._mock_repo() + repo.get_by_org.return_value = [_make_type()] + + service = EventTypeService(repository=repo) + result = service.get_org_specific_event_types("1") + + repo.get_by_org.assert_called_once_with(1) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].name, "Bootcamp") + + def test_get_all_event_types_for_org(self): + repo = self._mock_repo() + repo.get_all_for_org.return_value = [_make_type(), _make_type(id=2, org_id=None)] + + service = EventTypeService(repository=repo) + result = service.get_all_event_types_for_org("1") + + repo.get_all_for_org.assert_called_once_with(1) + self.assertEqual(len(result), 2) + + def test_get_event_type_by_id(self): + repo = self._mock_repo() + repo.get_by_id.return_value = _make_type(id=7) + + service = EventTypeService(repository=repo) + result = service.get_event_type_by_id(7) + + repo.get_by_id.assert_called_once_with(7) + self.assertEqual(result.id, 7) + + def test_create_org_specific_type(self): + repo = self._mock_repo() + service = EventTypeService(repository=repo) + service.create_org_specific_type("Swim", "SW", "third_f", "2") + + repo.create.assert_called_once_with("Swim", "SW", "third_f", 2) + + def test_update_org_specific_type(self): + repo = self._mock_repo() + service = EventTypeService(repository=repo) + service.update_org_specific_type(5, "Updated", "UP", "second_f") + + repo.update.assert_called_once_with(5, "Updated", "UP", "second_f") + + def test_delete_org_specific_type(self): + repo = self._mock_repo() + service = EventTypeService(repository=repo) + service.delete_org_specific_type(3) + + repo.delete.assert_called_once_with(3) + + +class EventTypeViewsTest(unittest.TestCase): + def test_build_add_type_modal_includes_note_and_list(self): + all_types = [_make_type()] + + form = EventTypeViews.build_add_type_modal(all_types) + + # Note block present (5 blocks: note, name, category, acronym, list) + self.assertEqual(len(form.blocks), 5) + list_block = form.get_block(CALENDAR_ADD_EVENT_TYPE_LIST) + self.assertIsNotNone(list_block) + self.assertIn("Bootcamp", list_block.text.text) + + def test_build_add_type_modal_category_options_populated(self): + form = EventTypeViews.build_add_type_modal([]) + + category_block = form.get_block(CALENDAR_ADD_EVENT_TYPE_CATEGORY) + self.assertIsNotNone(category_block) + self.assertEqual(len(category_block.element.options), 3) + option_values = [o.value for o in category_block.element.options] + self.assertIn("first_f", option_values) + self.assertIn("second_f", option_values) + self.assertIn("third_f", option_values) + + def test_build_edit_type_modal_removes_note_and_relabels(self): + event_type = _make_type() + all_types = [_make_type()] + + form = EventTypeViews.build_edit_type_modal(event_type, all_types) + + # Note block removed (4 blocks: name, category, acronym, list) + self.assertEqual(len(form.blocks), 4) + name_block = form.get_block(CALENDAR_ADD_EVENT_TYPE_NEW) + self.assertIsNotNone(name_block) + self.assertEqual(name_block.label.text, "Edit Event Type") + self.assertEqual(name_block.element.initial_value, "Bootcamp") + + def test_build_edit_type_modal_sets_initial_category(self): + event_type = _make_type(event_category="second_f") + form = EventTypeViews.build_edit_type_modal(event_type, []) + + category_block = form.get_block(CALENDAR_ADD_EVENT_TYPE_CATEGORY) + self.assertIsNotNone(category_block) + self.assertEqual(category_block.element.initial_option.value, "second_f") + + def test_build_type_list_modal_one_row_per_type(self): + org_types = [_make_type(id=1), _make_type(id=2, name="Ruck", acronym="RK")] + + form = EventTypeViews.build_type_list_modal(org_types) + + # 1 context block + 2 section blocks + self.assertEqual(len(form.blocks), 3) + self.assertEqual(form.blocks[1].block_id, f"{EVENT_TYPE_EDIT_DELETE}_1") + self.assertEqual(form.blocks[2].block_id, f"{EVENT_TYPE_EDIT_DELETE}_2") + + def test_build_type_list_modal_empty(self): + form = EventTypeViews.build_type_list_modal([]) + + # Only context block + self.assertEqual(len(form.blocks), 1) + + +class EventTypeHandlersTest(unittest.TestCase): + # ------------------------------------------------------------------ + # manage_event_types + # ------------------------------------------------------------------ + + @patch("features.calendar.event_type.add_loading_form") + @patch("features.calendar.event_type.EventTypeViews") + @patch("features.calendar.event_type._build_event_type_service") + def test_manage_event_types_add(self, mock_build_service, mock_views_cls, mock_loading): + body = {"actions": [{"selected_option": {"value": "add"}}], "trigger_id": "t1"} + client = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + mock_loading.return_value = "view123" + mock_service.get_all_event_types_for_org.return_value = [] + mock_modal = MagicMock() + mock_views_cls.return_value.build_add_type_modal.return_value = mock_modal + + manage_event_types(body, client, MagicMock(), {}, region_record) + + mock_service.get_all_event_types_for_org.assert_called_once_with("org1") + mock_views_cls.return_value.build_add_type_modal.assert_called_once_with([]) + mock_modal.update_modal.assert_called_once() + + @patch("features.calendar.event_type.EventTypeViews") + @patch("features.calendar.event_type._build_event_type_service") + def test_manage_event_types_edit(self, mock_build_service, mock_views_cls): + body = {"actions": [{"selected_option": {"value": "edit"}}], "trigger_id": "t1"} + client = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + mock_service.get_org_specific_event_types.return_value = [_make_type()] + mock_modal = MagicMock() + mock_views_cls.return_value.build_type_list_modal.return_value = mock_modal + + manage_event_types(body, client, MagicMock(), {}, region_record) + + mock_service.get_org_specific_event_types.assert_called_once_with("org1") + mock_views_cls.return_value.build_type_list_modal.assert_called_once() + mock_modal.post_modal.assert_called_once() + + # ------------------------------------------------------------------ + # handle_event_type_add — create + # ------------------------------------------------------------------ + + @patch("features.calendar.event_type.EVENT_TYPE_FORM") + @patch("features.calendar.event_type._build_event_type_service") + def test_handle_event_type_add_creates_new(self, mock_build_service, mock_form): + mock_form.get_selected_values.return_value = { + CALENDAR_ADD_EVENT_TYPE_NEW: "Bootcamp", + CALENDAR_ADD_EVENT_TYPE_CATEGORY: "first_f", + CALENDAR_ADD_EVENT_TYPE_ACRONYM: "BC", + } + body = {"view": {"private_metadata": "{}"}} + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + handle_event_type_add(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.create_org_specific_type.assert_called_once_with("Bootcamp", "BC", "first_f", "org1") + mock_service.update_org_specific_type.assert_not_called() + + @patch("features.calendar.event_type.EVENT_TYPE_FORM") + @patch("features.calendar.event_type._build_event_type_service") + def test_handle_event_type_add_defaults_acronym(self, mock_build_service, mock_form): + mock_form.get_selected_values.return_value = { + CALENDAR_ADD_EVENT_TYPE_NEW: "Bootcamp", + CALENDAR_ADD_EVENT_TYPE_CATEGORY: "first_f", + CALENDAR_ADD_EVENT_TYPE_ACRONYM: None, + } + body = {"view": {"private_metadata": "{}"}} + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + handle_event_type_add(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.create_org_specific_type.assert_called_once_with("Bootcamp", "Bo", "first_f", "org1") + + # ------------------------------------------------------------------ + # handle_event_type_add — update + # ------------------------------------------------------------------ + + @patch("features.calendar.event_type.EVENT_TYPE_FORM") + @patch("features.calendar.event_type._build_event_type_service") + def test_handle_event_type_add_updates_existing(self, mock_build_service, mock_form): + mock_form.get_selected_values.return_value = { + CALENDAR_ADD_EVENT_TYPE_NEW: "Updated", + CALENDAR_ADD_EVENT_TYPE_CATEGORY: "second_f", + CALENDAR_ADD_EVENT_TYPE_ACRONYM: "UP", + } + body = {"view": {"private_metadata": '{"edit_event_type_id": 42}'}} + region_record = MagicMock() + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + handle_event_type_add(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.update_org_specific_type.assert_called_once_with(42, "Updated", "UP", "second_f") + mock_service.create_org_specific_type.assert_not_called() + + # ------------------------------------------------------------------ + # handle_event_type_add — no-op when fields missing + # ------------------------------------------------------------------ + + @patch("features.calendar.event_type.EVENT_TYPE_FORM") + @patch("features.calendar.event_type._build_event_type_service") + def test_handle_event_type_add_noop_when_missing_fields(self, mock_build_service, mock_form): + mock_form.get_selected_values.return_value = { + CALENDAR_ADD_EVENT_TYPE_NEW: None, + CALENDAR_ADD_EVENT_TYPE_CATEGORY: None, + CALENDAR_ADD_EVENT_TYPE_ACRONYM: None, + } + body = {"view": {"private_metadata": "{}"}} + region_record = MagicMock() + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + handle_event_type_add(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.create_org_specific_type.assert_not_called() + mock_service.update_org_specific_type.assert_not_called() + + # ------------------------------------------------------------------ + # handle_event_type_edit_delete — edit + # ------------------------------------------------------------------ + + @patch("features.calendar.event_type.add_loading_form") + @patch("features.calendar.event_type.EventTypeViews") + @patch("features.calendar.event_type._build_event_type_service") + def test_handle_edit_delete_opens_edit_modal(self, mock_build_service, mock_views_cls, mock_loading): + body = { + "actions": [ + { + "action_id": f"{EVENT_TYPE_EDIT_DELETE}_1", + "selected_option": {"value": "Edit"}, + } + ] + } + client = MagicMock() + region_record = MagicMock() + region_record.org_id = "org1" + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + event_type = _make_type(id=1) + mock_service.get_all_event_types_for_org.return_value = [event_type] + mock_loading.return_value = "view456" + mock_modal = MagicMock() + mock_views_cls.return_value.build_edit_type_modal.return_value = mock_modal + + handle_event_type_edit_delete(body, client, MagicMock(), {}, region_record) + + mock_views_cls.return_value.build_edit_type_modal.assert_called_once_with(event_type, [event_type]) + mock_modal.update_modal.assert_called_once() + + @patch("features.calendar.event_type._build_event_type_service") + def test_handle_edit_delete_deletes_type(self, mock_build_service): + body = { + "actions": [ + { + "action_id": f"{EVENT_TYPE_EDIT_DELETE}_7", + "selected_option": {"value": "Delete"}, + } + ] + } + region_record = MagicMock() + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + handle_event_type_edit_delete(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.delete_org_specific_type.assert_called_once_with(7) + + @patch("features.calendar.event_type._build_event_type_service") + def test_handle_edit_delete_returns_early_on_missing_id(self, mock_build_service): + body = { + "actions": [ + { + "action_id": "", + "selected_option": {"value": "Delete"}, + } + ] + } + region_record = MagicMock() + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + handle_event_type_edit_delete(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.delete_org_specific_type.assert_not_called() + + +class BuildEventTypeServiceTest(unittest.TestCase): + @patch("features.calendar.event_type.get_api_event_type_repository") + @patch("features.calendar.event_type.EventTypeService") + def test_uses_api_repository(self, mock_svc_cls, mock_get_repo): + result = _build_event_type_service() + + mock_get_repo.assert_called_once_with() + mock_svc_cls.assert_called_once_with(repository=mock_get_repo.return_value) + self.assertEqual(result, mock_svc_cls.return_value) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/features/calendar/test_location.py b/apps/slackbot/tests/features/calendar/test_location.py new file mode 100644 index 00000000..21dedd72 --- /dev/null +++ b/apps/slackbot/tests/features/calendar/test_location.py @@ -0,0 +1,369 @@ +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +from application.location import LocationData +from application.location.service import LocationService +from features.calendar.location import ( + _LOCATION_CITY, + _LOCATION_COUNTRY, + _LOCATION_DESCRIPTION, + _LOCATION_LAT, + _LOCATION_LON, + _LOCATION_NAME, + _LOCATION_STATE, + _LOCATION_STREET, + _LOCATION_STREET2, + _LOCATION_ZIP, + LOCATION_EDIT_DELETE, + LocationViews, + _build_location_service, + handle_location_add, + handle_location_edit_delete, + manage_locations, +) + + +def _make_location( + id: int = 1, + name: str = "Central Park", + org_id: int = 10, + latitude: float = 34.05, + longitude: float = -118.24, +) -> LocationData: + return LocationData(id=id, name=name, org_id=org_id, latitude=latitude, longitude=longitude) + + +class LocationServiceTest(unittest.TestCase): + def _mock_repo(self): + return MagicMock() + + def test_get_org_locations_coerces_org_id(self): + repo = self._mock_repo() + repo.get_by_org.return_value = [_make_location()] + + service = LocationService(repository=repo) + result = service.get_org_locations("10") + + repo.get_by_org.assert_called_once_with(10) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].name, "Central Park") + + def test_get_org_locations_filters_inactive(self): + repo = self._mock_repo() + active = _make_location(id=1, name="Active") + inactive = LocationData(id=2, name="Inactive", org_id=10, is_active=False) + repo.get_by_org.return_value = [active, inactive] + + service = LocationService(repository=repo) + result = service.get_org_locations(10) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 1) + + def test_create_location_coerces_org_id(self): + repo = self._mock_repo() + repo.create.return_value = _make_location(id=5) + + service = LocationService(repository=repo) + result = service.create_location(name="Park", org_id="10") + + repo.create.assert_called_once() + call_kwargs = repo.create.call_args[1] + self.assertEqual(call_kwargs["org_id"], 10) + self.assertEqual(result.id, 5) + + def test_update_location_delegates(self): + repo = self._mock_repo() + service = LocationService(repository=repo) + service.update_location(location_id=7, name="Updated", org_id=10) + + repo.update.assert_called_once() + call_kwargs = repo.update.call_args[1] + self.assertEqual(call_kwargs["location_id"], 7) + self.assertEqual(call_kwargs["name"], "Updated") + self.assertEqual(call_kwargs["org_id"], 10) + + def test_delete_location_delegates(self): + repo = self._mock_repo() + service = LocationService(repository=repo) + service.delete_location(3) + + repo.delete.assert_called_once_with(3) + + def test_get_location_by_id_delegates(self): + repo = self._mock_repo() + repo.get_by_id.return_value = _make_location(id=9) + service = LocationService(repository=repo) + + result = service.get_location_by_id(9) + + repo.get_by_id.assert_called_once_with(9) + self.assertEqual(result.id, 9) + + +class LocationViewsTest(unittest.TestCase): + def test_build_add_modal_returns_ten_blocks(self): + form = LocationViews.build_add_modal() + self.assertEqual(len(form.blocks), 10) + + def test_build_add_modal_is_deep_copy(self): + form1 = LocationViews.build_add_modal() + form2 = LocationViews.build_add_modal() + self.assertIsNot(form1, form2) + self.assertIsNot(form1.blocks, form2.blocks) + + def test_build_edit_modal_sets_initial_values(self): + loc = LocationData( + id=1, + name="Riverside", + description="By the river", + latitude=35.0, + longitude=-90.0, + address_street="1 River Rd", + address_city="Memphis", + address_state="TN", + address_zip="38101", + address_country="USA", + org_id=5, + ) + form = LocationViews.build_edit_modal(loc) + # Check name block has initial value + name_block = form.get_block(_LOCATION_NAME) + self.assertIsNotNone(name_block) + self.assertEqual(name_block.element.initial_value, "Riverside") + # Check lat block + lat_block = form.get_block(_LOCATION_LAT) + self.assertIsNotNone(lat_block) + + def test_build_edit_modal_skips_none_fields(self): + loc = LocationData(id=2, name="Minimal", org_id=1) + form = LocationViews.build_edit_modal(loc) + name_block = form.get_block(_LOCATION_NAME) + self.assertEqual(name_block.element.initial_value, "Minimal") + # lat/lon blocks should have no initial_value since lat/lon are None + lat_block = form.get_block(_LOCATION_LAT) + self.assertIsNone(getattr(lat_block.element, "initial_value", None)) + + def test_build_list_modal_creates_section_per_location(self): + locations = [_make_location(id=1, name="Park A"), _make_location(id=2, name="Park B")] + form = LocationViews.build_list_modal(locations) + self.assertEqual(len(form.blocks), 2) + self.assertEqual(form.blocks[0].block_id, f"{LOCATION_EDIT_DELETE}_1") + self.assertEqual(form.blocks[1].block_id, f"{LOCATION_EDIT_DELETE}_2") + + def test_build_list_modal_empty(self): + form = LocationViews.build_list_modal([]) + self.assertEqual(len(form.blocks), 1) + self.assertEqual( + form.blocks[0].text.text, "No locations found. Please add a location to create AOs with a meetup spot." + ) + + def test_build_list_modal_with_notice_prepends_section(self): + locations = [_make_location(id=1, name="Park A")] + form = LocationViews.build_list_modal(locations, notice_text="A location was deleted.") + self.assertEqual(len(form.blocks), 2) + self.assertEqual(form.blocks[0].text.text, "A location was deleted.") + self.assertEqual(form.blocks[1].block_id, f"{LOCATION_EDIT_DELETE}_1") + + +class LocationHandlersTest(unittest.TestCase): + # ------------------------------------------------------------------ + # manage_locations + # ------------------------------------------------------------------ + + @patch("features.calendar.location.build_location_add_form") + def test_manage_locations_add(self, mock_build_add): + body = {"actions": [{"selected_option": {"value": "add"}}]} + client = MagicMock() + manage_locations(body, client, MagicMock(), MagicMock(), MagicMock()) + mock_build_add.assert_called_once() + + @patch("features.calendar.location._build_location_list_form") + def test_manage_locations_edit(self, mock_build_list): + body = {"actions": [{"selected_option": {"value": "edit"}}]} + manage_locations(body, MagicMock(), MagicMock(), MagicMock(), MagicMock()) + mock_build_list.assert_called_once() + + # ------------------------------------------------------------------ + # handle_location_add — create + # ------------------------------------------------------------------ + + @patch("features.calendar.location._build_location_service") + def test_handle_location_add_creates_new_location(self, mock_build_service): + mock_service = MagicMock() + mock_service.create_location.return_value = _make_location(id=99) + mock_build_service.return_value = mock_service + + body = { + "view": { + "private_metadata": "{}", + "state": { + "values": { + _LOCATION_NAME: {_LOCATION_NAME: {"type": "plain_text_input", "value": "New Park"}}, + _LOCATION_LAT: {_LOCATION_LAT: {"type": "number_input", "value": "34.05"}}, + _LOCATION_LON: {_LOCATION_LON: {"type": "number_input", "value": "-118.24"}}, + _LOCATION_DESCRIPTION: {_LOCATION_DESCRIPTION: {"type": "plain_text_input", "value": None}}, + _LOCATION_STREET: {_LOCATION_STREET: {"type": "plain_text_input", "value": None}}, + _LOCATION_STREET2: {_LOCATION_STREET2: {"type": "plain_text_input", "value": None}}, + _LOCATION_CITY: {_LOCATION_CITY: {"type": "plain_text_input", "value": None}}, + _LOCATION_STATE: {_LOCATION_STATE: {"type": "plain_text_input", "value": None}}, + _LOCATION_ZIP: {_LOCATION_ZIP: {"type": "plain_text_input", "value": None}}, + _LOCATION_COUNTRY: {_LOCATION_COUNTRY: {"type": "plain_text_input", "value": None}}, + } + }, + } + } + region_record = MagicMock() + region_record.org_id = 10 + + with patch("features.calendar.location.trigger_map_revalidation"): + handle_location_add(body, MagicMock(), MagicMock(), MagicMock(), region_record) + + mock_service.create_location.assert_called_once() + call_kwargs = mock_service.create_location.call_args[1] + self.assertEqual(call_kwargs["name"], "New Park") + self.assertEqual(call_kwargs["org_id"], 10) + + @patch("features.calendar.location._build_location_service") + def test_handle_location_add_updates_existing_location(self, mock_build_service): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + body = { + "view": { + "private_metadata": '{"location_id": 7}', + "state": { + "values": { + _LOCATION_NAME: {_LOCATION_NAME: {"type": "plain_text_input", "value": "Edited Park"}}, + _LOCATION_LAT: {_LOCATION_LAT: {"type": "number_input", "value": "35.0"}}, + _LOCATION_LON: {_LOCATION_LON: {"type": "number_input", "value": "-119.0"}}, + _LOCATION_DESCRIPTION: {_LOCATION_DESCRIPTION: {"type": "plain_text_input", "value": None}}, + _LOCATION_STREET: {_LOCATION_STREET: {"type": "plain_text_input", "value": None}}, + _LOCATION_STREET2: {_LOCATION_STREET2: {"type": "plain_text_input", "value": None}}, + _LOCATION_CITY: {_LOCATION_CITY: {"type": "plain_text_input", "value": None}}, + _LOCATION_STATE: {_LOCATION_STATE: {"type": "plain_text_input", "value": None}}, + _LOCATION_ZIP: {_LOCATION_ZIP: {"type": "plain_text_input", "value": None}}, + _LOCATION_COUNTRY: {_LOCATION_COUNTRY: {"type": "plain_text_input", "value": None}}, + } + }, + } + } + region_record = MagicMock() + + with patch("features.calendar.location.trigger_map_revalidation"): + handle_location_add(body, MagicMock(), MagicMock(), MagicMock(), region_record) + + mock_service.update_location.assert_called_once() + call_kwargs = mock_service.update_location.call_args[1] + self.assertEqual(call_kwargs["location_id"], 7) + self.assertEqual(call_kwargs["name"], "Edited Park") + self.assertEqual(call_kwargs["org_id"], region_record.org_id) + + # ------------------------------------------------------------------ + # handle_location_edit_delete + # ------------------------------------------------------------------ + + @patch("features.calendar.location._build_location_service") + @patch("features.calendar.location.build_location_add_form") + def test_handle_location_edit_delete_edit(self, mock_build_add, mock_build_service): + mock_service = MagicMock() + mock_service.get_location_by_id.return_value = _make_location(id=5) + mock_build_service.return_value = mock_service + + body = { + "actions": [ + { + "action_id": f"{LOCATION_EDIT_DELETE}_5", + "selected_option": {"value": "Edit"}, + } + ] + } + handle_location_edit_delete(body, MagicMock(), MagicMock(), MagicMock(), MagicMock()) + + mock_service.get_location_by_id.assert_called_once_with(5) + mock_build_add.assert_called_once() + + @patch("features.calendar.location._build_location_service") + def test_handle_location_edit_delete_delete(self, mock_build_service): + mock_service = MagicMock() + mock_service.get_org_locations.return_value = [_make_location(id=5, name="Central Park")] + mock_build_service.return_value = mock_service + + body = { + "view": {"id": "V123"}, + "actions": [ + { + "action_id": f"{LOCATION_EDIT_DELETE}_5", + "selected_option": {"value": "Delete"}, + } + ], + } + region_record = MagicMock() + mock_form = MagicMock() + with ( + patch("features.calendar.location.trigger_map_revalidation"), + patch.object(LocationViews, "build_list_modal", return_value=mock_form) as mock_build_list, + ): + handle_location_edit_delete(body, MagicMock(), MagicMock(), MagicMock(), region_record) + + mock_service.delete_location.assert_called_once_with(5) + mock_form.update_modal.assert_called_once() + self.assertEqual(mock_form.update_modal.call_args[1]["view_id"], "V123") + notice = mock_build_list.call_args[1].get("notice_text", "") or "" + self.assertIn("Central Park", notice) + + @patch("features.calendar.location._build_location_service") + def test_handle_location_edit_delete_missing_id_returns_early(self, mock_build_service): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + body = { + "actions": [ + { + "action_id": "", + "selected_option": {"value": "Edit"}, + } + ] + } + handle_location_edit_delete(body, MagicMock(), MagicMock(), MagicMock(), MagicMock()) + + mock_service.get_location_by_id.assert_not_called() + + @patch("features.calendar.location._build_location_service") + @patch("features.calendar.location.build_location_add_form") + def test_handle_location_edit_delete_location_not_found_returns_early(self, mock_build_add, mock_build_service): + mock_service = MagicMock() + mock_service.get_location_by_id.return_value = None + mock_build_service.return_value = mock_service + + body = { + "actions": [ + { + "action_id": f"{LOCATION_EDIT_DELETE}_99", + "selected_option": {"value": "Edit"}, + } + ] + } + handle_location_edit_delete(body, MagicMock(), MagicMock(), MagicMock(), MagicMock()) + + mock_build_add.assert_not_called() + + # ------------------------------------------------------------------ + # Composition root + # ------------------------------------------------------------------ + + @patch("features.calendar.location.get_api_location_repository") + @patch("features.calendar.location.LocationService") + def test_build_location_service_uses_api_repository(self, mock_svc_cls, mock_get_repo): + result = _build_location_service() + mock_get_repo.assert_called_once_with() + mock_svc_cls.assert_called_once_with(repository=mock_get_repo.return_value) + self.assertEqual(result, mock_svc_cls.return_value) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/features/calendar/test_series.py b/apps/slackbot/tests/features/calendar/test_series.py new file mode 100644 index 00000000..f5dddaad --- /dev/null +++ b/apps/slackbot/tests/features/calendar/test_series.py @@ -0,0 +1,407 @@ +import json +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +from application.series import SeriesData +from application.series.service import SeriesService +from features.calendar.series import ( + _build_series_service, + build_series_list_form, + handle_series_add, + handle_series_edit_delete, + manage_series, +) +from utilities.database.orm import SlackSettings + + +def _make_series( + id: int = 1, + name: str = "Test Series", + org_id: int = 10, + region_id: int = 5, + start_date: str = "2025-01-06", + end_date: str | None = None, + day_of_week: str = "monday", + start_time: str = "0530", + end_time: str = "0615", + event_type_ids: list | None = None, + event_tag_ids: list | None = None, + meta: dict | None = None, + is_private: bool = False, + highlight: bool = False, +) -> SeriesData: + return SeriesData( + id=id, + name=name, + org_id=org_id, + region_id=region_id, + start_date=start_date, + end_date=end_date, + day_of_week=day_of_week, + start_time=start_time, + end_time=end_time, + event_type_ids=event_type_ids or [], + event_tag_ids=event_tag_ids or [], + meta=meta, + is_private=is_private, + highlight=highlight, + ) + + +def _make_region_record(org_id: int = 5) -> SlackSettings: + r = MagicMock(spec=SlackSettings) + r.org_id = org_id + return r + + +def _make_series_service( + region_series: list | None = None, + by_id: SeriesData | None = None, + created: SeriesData | None = None, + updated: SeriesData | None = None, +) -> SeriesService: + svc = MagicMock(spec=SeriesService) + svc.get_region_series.return_value = region_series or [] + svc.get_by_id.return_value = by_id + svc.create_series.return_value = created or _make_series(id=99) + svc.update_series.return_value = updated or _make_series(id=1) + return svc + + +class ManageSeriesTest(unittest.TestCase): + @patch("features.calendar.series.build_series_add_form") + def test_dispatches_add(self, mock_add): + body = {"actions": [{"selected_option": {"value": "add"}}]} + client = MagicMock() + manage_series(body, client, MagicMock(), {}, _make_region_record()) + mock_add.assert_called_once() + + @patch("features.calendar.series.build_series_list_form") + def test_dispatches_edit(self, mock_list): + body = {"actions": [{"selected_option": {"value": "edit"}}]} + client = MagicMock() + manage_series(body, client, MagicMock(), {}, _make_region_record()) + mock_list.assert_called_once() + + +class BuildSeriesListFormTest(unittest.TestCase): + def _patch_services(self, series_list=None, ao_list=None): + series_svc = _make_series_service(region_series=series_list or []) + ao_svc = MagicMock() + ao_svc.get_region_aos.return_value = ao_list or [] + return series_svc, ao_svc + + @patch("features.calendar.series._build_ao_service") + @patch("features.calendar.series._build_series_service") + def test_no_filter_uses_region_id(self, mock_build_series, mock_build_ao): + series_svc, ao_svc = self._patch_services( + series_list=[_make_series(id=1, name="Monday Workout", day_of_week="monday")] + ) + mock_build_series.return_value = series_svc + mock_build_ao.return_value = ao_svc + + body = { + "actions": [{"action_id": "other_action"}], + "view": {"id": "V123", "private_metadata": "{}"}, + } + region = _make_region_record(org_id=5) + client = MagicMock() + + build_series_list_form(body, client, MagicMock(), {}, region, update_view_id="V123") + + series_svc.get_region_series.assert_called_once_with(5) + client.views_update.assert_called_once() + + @patch("features.calendar.series._build_ao_service") + @patch("features.calendar.series._build_series_service") + def test_ao_filter_uses_ao_id(self, mock_build_series, mock_build_ao): + series_svc = _make_series_service() + ao_svc = MagicMock() + ao_svc.get_region_aos.return_value = [] + mock_build_series.return_value = series_svc + mock_build_ao.return_value = ao_svc + + # Simulate an AO filter action with a selected AO value + filter_block_id = "calendar_manage_series_ao" + body = { + "actions": [{"action_id": filter_block_id}], + "view": { + "id": "V456", + "blocks": [ + { + "block_id": filter_block_id, + "element": { + "type": "static_select", + "selected_option": {"value": "10", "text": {"text": "AO Name"}}, + }, + } + ], + "state": { + "values": { + filter_block_id: { + filter_block_id: { + "type": "static_select", + "selected_option": {"value": "10", "text": {"text": "AO Name"}}, + } + } + } + }, + "private_metadata": "{}", + }, + } + region = _make_region_record(org_id=5) + client = MagicMock() + + build_series_list_form(body, client, MagicMock(), {}, region) + + # filter_org should be 10, so get_region_series called with ao_id=10 + series_svc.get_region_series.assert_called_once_with(5, ao_id=10) + + @patch("features.calendar.series._build_ao_service") + @patch("features.calendar.series._build_series_service") + def test_label_uses_string_day_of_week(self, mock_build_series, mock_build_ao): + series = _make_series(id=1, name="Workout", day_of_week="wednesday", start_time="0600") + series_svc = _make_series_service(region_series=[series]) + ao_svc = MagicMock() + ao_svc.get_region_aos.return_value = [] + mock_build_series.return_value = series_svc + mock_build_ao.return_value = ao_svc + + body = { + "actions": [{"action_id": "other"}], + "view": {"id": "V789", "private_metadata": "{}"}, + } + client = MagicMock() + + build_series_list_form(body, client, MagicMock(), {}, _make_region_record(), update_view_id="V789") + call_kwargs = client.views_update.call_args.kwargs + blocks = call_kwargs["view"]["blocks"] + # The section block label should contain "Wednesday" (capitalized string) + labels = [b.get("text", {}).get("text", "") for b in blocks if b.get("type") == "section"] + self.assertTrue(any("Wednesday" in label for label in labels)) + + +class HandleSeriesEditDeleteTest(unittest.TestCase): + @patch("features.calendar.series.build_series_add_form") + @patch("features.calendar.series._build_series_service") + def test_edit_fetches_series_and_builds_form(self, mock_build_svc, mock_build_form): + series = _make_series(id=7) + svc = _make_series_service(by_id=series) + mock_build_svc.return_value = svc + + body = { + "actions": [ + { + "action_id": "series-edit-delete_7", + "selected_option": {"value": "Edit"}, + } + ], + "view": {"id": "V1", "private_metadata": "{}"}, + } + client = MagicMock() + handle_series_edit_delete(body, client, MagicMock(), {}, _make_region_record()) + + svc.get_by_id.assert_called_once_with(7) + mock_build_form.assert_called_once() + # Ensure edit_event is passed + call_kwargs = mock_build_form.call_args.kwargs + self.assertEqual(call_kwargs["edit_event"], series) + + @patch("features.calendar.series.build_series_list_form") + @patch("features.calendar.series.trigger_map_revalidation") + @patch("features.calendar.series._build_series_service") + def test_delete_calls_delete_service_and_shows_list(self, mock_build_svc, mock_trigger, mock_list_form): + svc = _make_series_service() + mock_build_svc.return_value = svc + + body = { + "actions": [ + { + "action_id": "series-edit-delete_7", + "selected_option": {"value": "Delete"}, + } + ], + "view": {"id": "V1", "private_metadata": "{}"}, + } + client = MagicMock() + handle_series_edit_delete(body, client, MagicMock(), {}, _make_region_record()) + + svc.delete_series.assert_called_once_with(7) + mock_trigger.assert_called_once() + mock_list_form.assert_called_once() + + @patch("features.calendar.series.build_series_list_form") + @patch("features.calendar.series.trigger_map_revalidation") + @patch("features.calendar.series._build_series_service") + def test_delete_sets_is_series_metadata(self, mock_build_svc, mock_trigger, mock_list_form): + svc = _make_series_service() + mock_build_svc.return_value = svc + + body = { + "actions": [ + { + "action_id": "series-edit-delete_7", + "selected_option": {"value": "Delete"}, + } + ], + "view": {"id": "V1", "private_metadata": "{}"}, + } + handle_series_edit_delete(body, MagicMock(), MagicMock(), {}, _make_region_record()) + metadata = json.loads(body["view"]["private_metadata"]) + self.assertEqual(metadata.get("is_series"), "True") + + +class HandleSeriesAddUpdateTest(unittest.TestCase): + """Tests for the update (edit) path of handle_series_add.""" + + def _make_body( + self, + series_id: int = 1, + series_name: str = "Test Series", + ao_id: str = "10", + event_type_id: str = "42", + start_time: str = "05:30", + end_time: str = "06:15", + description: str = "", + selected_options: list | None = None, + ) -> dict: + ao_block_id = "calendar_add_series_ao" + location_block_id = "calendar_add_series_location" + event_type_block_id = "calendar_add_series_type" + start_time_block_id = "calendar_add_series_start_time" + end_time_block_id = "calendar_add_series_end_time" + name_block_id = "calendar_add_series_name" + description_block_id = "calendar_add_series_description" + options_block_id = "calendar_add_series_options" + + values = { + ao_block_id: { + ao_block_id: {"type": "static_select", "selected_option": {"value": ao_id, "text": {"text": "AO"}}} + }, + location_block_id: { + location_block_id: { + "type": "static_select", + "selected_option": {"value": "20", "text": {"text": "Park"}}, + } + }, + event_type_block_id: { + event_type_block_id: { + "type": "static_select", + "selected_option": {"value": event_type_id, "text": {"text": "Bootcamp"}}, + } + }, + start_time_block_id: {start_time_block_id: {"type": "timepicker", "selected_time": start_time}}, + end_time_block_id: {end_time_block_id: {"type": "timepicker", "selected_time": end_time}}, + name_block_id: {name_block_id: {"type": "plain_text_input", "value": series_name}}, + description_block_id: {description_block_id: {"type": "plain_text_input", "value": description}}, + options_block_id: { + options_block_id: { + "type": "checkboxes", + "selected_options": [{"value": o, "text": {"text": o}} for o in (selected_options or [])], + } + }, + } + + blocks = [ + {"block_id": ao_block_id, "element": {"type": "static_select", "initial_option": None}}, + {"block_id": location_block_id, "element": {"type": "static_select", "initial_option": {"value": "20"}}}, + { + "block_id": event_type_block_id, + "element": {"type": "static_select", "initial_option": {"value": event_type_id}}, + }, + {"block_id": start_time_block_id, "element": {"type": "timepicker"}}, + {"block_id": end_time_block_id, "element": {"type": "timepicker"}}, + {"block_id": name_block_id, "element": {"type": "plain_text_input"}}, + {"block_id": description_block_id, "element": {"type": "plain_text_input"}}, + {"block_id": options_block_id, "element": {"type": "checkboxes"}}, + ] + + return { + "view": { + "id": "V1", + "previous_view_id": "V0", + "private_metadata": json.dumps({"series_id": str(series_id)}), + "blocks": blocks, + "state": {"values": values}, + }, + "actions": [{"action_id": "add_series_callback_id"}], + } + + @patch("features.calendar.series.build_series_list_form") + @patch("features.calendar.series.trigger_map_revalidation") + @patch("features.calendar.series._build_event_type_service") + @patch("features.calendar.series._build_ao_service") + @patch("features.calendar.series._build_series_service") + def test_update_preserves_start_date_from_existing( + self, mock_build_series, mock_build_ao, mock_build_et, mock_trigger, mock_list + ): + existing = _make_series(id=1, start_date="2025-01-06", end_date="2026-01-01") + svc = _make_series_service(by_id=existing) + mock_build_series.return_value = svc + mock_build_ao.return_value = MagicMock() + mock_build_et.return_value = MagicMock() + + body = self._make_body(series_id=1) + client = MagicMock() + + handle_series_add(body, client, MagicMock(), {}, _make_region_record()) + + svc.update_series.assert_called_once() + call_kwargs = svc.update_series.call_args.kwargs + self.assertEqual(call_kwargs["start_date"], "2025-01-06") + self.assertEqual(call_kwargs["end_date"], "2026-01-01") + + @patch("features.calendar.series.build_series_list_form") + @patch("features.calendar.series.trigger_map_revalidation") + @patch("features.calendar.series._build_event_type_service") + @patch("features.calendar.series._build_ao_service") + @patch("features.calendar.series._build_series_service") + def test_update_merges_meta_flags(self, mock_build_series, mock_build_ao, mock_build_et, mock_trigger, mock_list): + existing = _make_series(id=1, meta={"existing_key": True}) + svc = _make_series_service(by_id=existing) + mock_build_series.return_value = svc + mock_build_ao.return_value = MagicMock() + mock_build_et.return_value = MagicMock() + + body = self._make_body(series_id=1, selected_options=["no_auto_preblasts"]) + handle_series_add(body, MagicMock(), MagicMock(), {}, _make_region_record()) + + call_kwargs = svc.update_series.call_args.kwargs + self.assertEqual(call_kwargs["meta"].get("do_not_send_auto_preblasts"), True) + self.assertEqual(call_kwargs["meta"].get("existing_key"), True) + + @patch("features.calendar.series.build_series_list_form") + @patch("features.calendar.series.trigger_map_revalidation") + @patch("features.calendar.series._build_event_type_service") + @patch("features.calendar.series._build_ao_service") + @patch("features.calendar.series._build_series_service") + def test_update_no_meta_flags_passes_none( + self, mock_build_series, mock_build_ao, mock_build_et, mock_trigger, mock_list + ): + existing = _make_series(id=1, meta=None) + svc = _make_series_service(by_id=existing) + mock_build_series.return_value = svc + mock_build_ao.return_value = MagicMock() + mock_build_et.return_value = MagicMock() + + body = self._make_body(series_id=1) + handle_series_add(body, MagicMock(), MagicMock(), {}, _make_region_record()) + + call_kwargs = svc.update_series.call_args.kwargs + self.assertIsNone(call_kwargs["meta"]) + + +class BuildSeriesServiceTest(unittest.TestCase): + @patch("features.calendar.series.get_api_series_repository") + def test_returns_series_service(self, mock_get_repo): + mock_get_repo.return_value = MagicMock() + svc = _build_series_service() + self.assertIsInstance(svc, SeriesService) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/features/test_positions.py b/apps/slackbot/tests/features/test_positions.py new file mode 100644 index 00000000..53ab2f66 --- /dev/null +++ b/apps/slackbot/tests/features/test_positions.py @@ -0,0 +1,399 @@ +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from application.position import PositionData, PositionWithAssignmentsData, UserAssignmentData +from application.position.service import PositionService +from features.positions import ( + PositionViews, + _build_position_service, + build_config_slt_form, + handle_config_slt_post, + handle_edit_position_post, + handle_new_position_post, + handle_position_edit_delete, +) + +# --------------------------------------------------------------------------- +# Test fixtures +# --------------------------------------------------------------------------- + + +def _make_position(id=1, name="President", org_id=10, description="Top leader", is_active=True): + return PositionData(id=id, name=name, description=description, org_id=org_id, is_active=is_active) + + +def _make_position_with_users(id=1, name="President", users=None): + return PositionWithAssignmentsData( + id=id, + name=name, + description="Role description", + org_id=10, + is_active=True, + users=users or [], + ) + + +def _make_region_record(org_id=10, team_id="T123"): + record = MagicMock() + record.org_id = org_id + record.team_id = team_id + return record + + +# --------------------------------------------------------------------------- +# PositionService tests +# --------------------------------------------------------------------------- + + +class PositionServiceTest(unittest.TestCase): + def _mock_repo(self): + return MagicMock() + + def test_get_org_positions_coerces_string_org_id(self): + repo = self._mock_repo() + repo.get_by_org.return_value = [_make_position()] + service = PositionService(repository=repo) + + result = service.get_org_positions("10") + + repo.get_by_org.assert_called_once_with(10) + self.assertEqual(len(result), 1) + + def test_get_positions_with_assignments_passes_both_ids(self): + repo = self._mock_repo() + repo.get_assignments.return_value = [] + service = PositionService(repository=repo) + + service.get_positions_with_assignments("5", "10") + + repo.get_assignments.assert_called_once_with(5, 10) + + def test_create_position(self): + repo = self._mock_repo() + repo.create.return_value = _make_position(id=99) + service = PositionService(repository=repo) + + result = service.create_position("VP", "Vice President", "10", "region") + + repo.create.assert_called_once_with("VP", "Vice President", 10, "region") + self.assertEqual(result.id, 99) + + def test_update_position(self): + repo = self._mock_repo() + service = PositionService(repository=repo) + + service.update_position(3, "New Name", "New Desc") + + repo.update.assert_called_once_with(3, "New Name", "New Desc") + + def test_delete_position(self): + repo = self._mock_repo() + service = PositionService(repository=repo) + + service.delete_position(5) + + repo.delete.assert_called_once_with(5) + + def test_update_org_assignments_coerces_org_id(self): + repo = self._mock_repo() + service = PositionService(repository=repo) + assignments = [{"positionId": 1, "userIds": [42]}] + + service.update_org_assignments("10", assignments) + + repo.update_all_assignments.assert_called_once_with(10, assignments) + + +# --------------------------------------------------------------------------- +# PositionViews tests +# --------------------------------------------------------------------------- + + +class PositionViewsBuildSltModalTest(unittest.TestCase): + def _make_ao(self, id=20, name="Alpha AO"): + ao = MagicMock() + ao.id = id + ao.name = name + return ao + + def test_build_slt_modal_includes_level_selector(self): + form = PositionViews.build_slt_modal( + position_assignments=[], + aos=[self._make_ao()], + org_id=10, + region_org_id=10, + user_id_to_slack_id={}, + ) + # first block is the level selector + self.assertEqual(form.blocks[0].action, "slt-level-select") + + def test_build_slt_modal_maps_user_ids_to_slack_ids(self): + user = UserAssignmentData(user_id=42, f3_name="Dredd") + position = _make_position_with_users(id=1, users=[user]) + form = PositionViews.build_slt_modal( + position_assignments=[position], + aos=[], + org_id=10, + region_org_id=10, + user_id_to_slack_id={42: "USLACK1"}, + ) + # second block should be the position block with initial_value set + position_block = form.blocks[1] + self.assertEqual(position_block.element.initial_value, ["USLACK1"]) + + def test_build_slt_modal_skips_unmapped_user_ids(self): + user = UserAssignmentData(user_id=999, f3_name="Ghost") + position = _make_position_with_users(id=1, users=[user]) + form = PositionViews.build_slt_modal( + position_assignments=[position], + aos=[], + org_id=10, + region_org_id=10, + user_id_to_slack_id={}, # 999 not mapped + ) + position_block = form.blocks[1] + # initial_value should not be set when no mapped users + self.assertFalse(hasattr(position_block.element, "initial_value") and position_block.element.initial_value) + + def test_build_slt_modal_shows_region_initial_when_org_matches(self): + form = PositionViews.build_slt_modal( + position_assignments=[], + aos=[], + org_id=10, + region_org_id=10, + user_id_to_slack_id={}, + ) + level_block = form.blocks[0] + self.assertEqual(level_block.element.initial_value, "0") + + def test_build_slt_modal_shows_ao_initial_when_org_differs(self): + form = PositionViews.build_slt_modal( + position_assignments=[], + aos=[], + org_id=20, + region_org_id=10, + user_id_to_slack_id={}, + ) + level_block = form.blocks[0] + self.assertEqual(level_block.element.initial_value, "20") + + +class PositionViewsBuildListModalTest(unittest.TestCase): + def test_build_position_list_modal_shows_empty_message(self): + form = PositionViews.build_position_list_modal([]) + # first block is context, second is the "no positions" message + self.assertEqual(len(form.blocks), 2) + + def test_build_position_list_modal_shows_positions(self): + positions = [_make_position(id=1, name="Alpha"), _make_position(id=2, name="Beta")] + form = PositionViews.build_position_list_modal(positions) + # context block + 2 position blocks + self.assertEqual(len(form.blocks), 3) + + +# --------------------------------------------------------------------------- +# Handler tests +# --------------------------------------------------------------------------- + + +class BuildConfigSltFormTest(unittest.TestCase): + @patch("features.positions._build_position_service") + @patch("features.positions.DbManager") + @patch("features.positions._user_id_to_slack_id_map", return_value={}) + @patch("features.positions.PositionViews.build_slt_modal") + def test_build_config_slt_form_posts_modal( + self, mock_build_modal, mock_uid_map, mock_dbmanager, mock_build_service + ): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + mock_service.get_positions_with_assignments.return_value = [] + mock_dbmanager.find_records.return_value = [] + mock_build_modal.return_value = MagicMock() + + body = {"trigger_id": "T1"} + client = MagicMock() + region_record = _make_region_record() + + build_config_slt_form(body, client, MagicMock(), {}, region_record) + + mock_service.get_positions_with_assignments.assert_called_once_with(region_record.org_id, region_record.org_id) + mock_build_modal.return_value.post_modal.assert_called_once() + + @patch("features.positions._build_position_service") + @patch("features.positions.DbManager") + @patch("features.positions._user_id_to_slack_id_map", return_value={}) + @patch("features.positions.PositionViews.build_slt_modal") + def test_build_config_slt_form_updates_on_level_change( + self, mock_build_modal, mock_uid_map, mock_dbmanager, mock_build_service + ): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + mock_service.get_positions_with_assignments.return_value = [] + mock_dbmanager.find_records.return_value = [] + mock_build_modal.return_value = MagicMock() + + body = { + "actions": [{"action_id": "slt-level-select", "selected_option": {"value": "20"}}], + "view": {"id": "V1"}, + } + region_record = _make_region_record() + + build_config_slt_form(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.get_positions_with_assignments.assert_called_once_with(20, region_record.org_id) + mock_build_modal.return_value.update_modal.assert_called_once() + + +class HandleConfigSltPostTest(unittest.TestCase): + @patch("features.positions._build_position_service") + @patch("features.positions.get_user") + def test_handle_config_slt_post_calls_update_assignments(self, mock_get_user, mock_build_service): + mock_slack_user = MagicMock() + mock_slack_user.user_id = 42 + mock_get_user.return_value = mock_slack_user + + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + body = {"view": {"state": {"values": {"slt-select1_10": {"slt-select1_10": {"selected_users": ["USLACK1"]}}}}}} + region_record = _make_region_record(org_id=10) + + handle_config_slt_post(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.update_org_assignments.assert_called_once_with( + org_id=10, + assignments=[{"positionId": 1, "userIds": [42]}], + ) + + @patch("features.positions._build_position_service") + @patch("features.positions.get_user") + def test_handle_config_slt_post_maps_zero_org_to_region(self, mock_get_user, mock_build_service): + mock_get_user.return_value = MagicMock(user_id=99) + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + # org_id=0 should be replaced with region_record.org_id + body = {"view": {"state": {"values": {"slt-select2_0": {"slt-select2_0": {"selected_users": ["U1"]}}}}}} + region_record = _make_region_record(org_id=10) + + handle_config_slt_post(body, MagicMock(), MagicMock(), {}, region_record) + + call_args = mock_service.update_org_assignments.call_args + self.assertEqual(call_args.kwargs["org_id"], 10) + + +class HandleNewPositionPostTest(unittest.TestCase): + @patch("features.positions._build_position_service") + @patch("features.positions.build_config_slt_form") + def test_handle_new_position_post_creates_and_refreshes(self, mock_refresh, mock_build_service): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + body = { + "view": { + "private_metadata": '{"org_id": 10}', + "previous_view_id": "V_PREV", + "state": {"values": {}}, + "blocks": [], + } + } + region_record = _make_region_record(org_id=10) + + with patch("features.positions.forms.CONFIG_NEW_POSITION_FORM") as mock_form: + mock_form.get_selected_values.return_value = { + "new_position_name": "Treasurer", + "new_position_description": "Handles money", + } + handle_new_position_post(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.create_position.assert_called_once() + mock_refresh.assert_called_once() + + +class HandlePositionEditDeleteTest(unittest.TestCase): + @patch("features.positions._build_position_service") + @patch("features.positions.build_position_list_form") + def test_delete_calls_service_and_refreshes(self, mock_list_form, mock_build_service): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + body = { + "actions": [{"action_id": "position-edit-delete_5", "selected_option": {"value": "Delete"}}], + "view": {"id": "V1"}, + } + region_record = _make_region_record() + + handle_position_edit_delete(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.delete_position.assert_called_once_with(5) + mock_list_form.assert_called_once() + + @patch("features.positions._build_position_service") + @patch("features.positions.build_edit_position_form") + def test_edit_fetches_position_and_opens_form(self, mock_edit_form, mock_build_service): + mock_service = MagicMock() + mock_service.get_by_id.return_value = _make_position(id=3) + mock_build_service.return_value = mock_service + + body = { + "actions": [{"action_id": "position-edit-delete_3", "selected_option": {"value": "Edit"}}], + "view": {"id": "V1"}, + } + region_record = _make_region_record() + + handle_position_edit_delete(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.get_by_id.assert_called_once_with(3) + mock_edit_form.assert_called_once() + + +class HandleEditPositionPostTest(unittest.TestCase): + @patch("features.positions._build_position_service") + @patch("features.positions.build_config_slt_form") + def test_handle_edit_position_post_updates_and_refreshes(self, mock_refresh, mock_build_service): + mock_service = MagicMock() + mock_build_service.return_value = mock_service + + body = { + "view": { + "private_metadata": '{"position_id": 7}', + "previous_view_id": "V_PREV", + "state": {"values": {}}, + "blocks": [], + } + } + region_record = _make_region_record() + + with patch("features.positions.forms.CONFIG_NEW_POSITION_FORM") as mock_form: + mock_form.get_selected_values.return_value = { + "new_position_name": "Treasurer", + "new_position_description": "Updated desc", + } + handle_edit_position_post(body, MagicMock(), MagicMock(), {}, region_record) + + mock_service.update_position.assert_called_once() + mock_refresh.assert_called_once() + + +# --------------------------------------------------------------------------- +# Composition root test +# --------------------------------------------------------------------------- + + +class BuildPositionServiceTest(unittest.TestCase): + @patch("features.positions.get_api_position_repository") + @patch("features.positions.PositionService") + def test_build_position_service_uses_api_repository(self, mock_svc_cls, mock_get_repo): + result = _build_position_service() + + mock_get_repo.assert_called_once_with() + mock_svc_cls.assert_called_once_with(repository=mock_get_repo.return_value) + self.assertIs(result, mock_svc_cls.return_value) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/infrastructure/api_client/test_ao_repository.py b/apps/slackbot/tests/infrastructure/api_client/test_ao_repository.py new file mode 100644 index 00000000..c39acdc2 --- /dev/null +++ b/apps/slackbot/tests/infrastructure/api_client/test_ao_repository.py @@ -0,0 +1,227 @@ +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +from infrastructure.api_client.ao_repository import ApiAoRepository, get_api_ao_repository +from infrastructure.api_client.exceptions import F3ApiNotFoundError + + +def _raw_ao( + id: int = 1, + name: str = "The Grind", + parent_id: int = 10, + is_active: bool = True, + default_location_id: int = None, + logo_url: str = None, + meta: dict = None, +) -> dict: + return { + "id": id, + "name": name, + "parentId": parent_id, + "orgType": "ao", + "isActive": is_active, + "defaultLocationId": default_location_id, + "logoUrl": logo_url, + "meta": meta or {}, + } + + +class ApiAoRepositoryTest(unittest.TestCase): + def setUp(self): + self.client = MagicMock() + self.repo = ApiAoRepository(self.client) + + # ------------------------------------------------------------------ + # get_by_parent_org + # ------------------------------------------------------------------ + + def test_get_by_parent_org_returns_active_aos(self): + self.client.get.return_value = {"orgs": [_raw_ao(id=1), _raw_ao(id=2)]} + result = self.repo.get_by_parent_org(10) + self.client.get.assert_called_once_with( + "/v1/org", + params={"orgTypes": ["ao"], "parentOrgIds": [10], "statuses": ["active"]}, + ) + self.assertEqual(len(result), 2) + self.assertEqual(result[0].id, 1) + self.assertEqual(result[0].name, "The Grind") + + def test_get_by_parent_org_supports_results_fallback(self): + self.client.get.return_value = {"results": [_raw_ao(id=99)]} + result = self.repo.get_by_parent_org(10) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 99) + + def test_get_by_parent_org_returns_empty_list_when_no_key(self): + self.client.get.return_value = {"unexpected": []} + result = self.repo.get_by_parent_org(10) + self.assertEqual(result, []) + + def test_get_by_parent_org_parses_snake_case_fields(self): + self.client.get.return_value = { + "orgs": [ + { + "id": 5, + "name": "Snake AO", + "parent_id": 10, + "org_type": "ao", + "is_active": True, + "default_location_id": 42, + "logo_url": "http://example.com/logo.png", + "meta": {"slack_channel_id": "C123"}, + } + ] + } + result = self.repo.get_by_parent_org(10) + ao = result[0] + self.assertEqual(ao.parent_id, 10) + self.assertEqual(ao.default_location_id, 42) + self.assertEqual(ao.logo_url, "http://example.com/logo.png") + self.assertEqual(ao.meta, {"slack_channel_id": "C123"}) + + # ------------------------------------------------------------------ + # get_by_id + # ------------------------------------------------------------------ + + def test_get_by_id_returns_ao(self): + self.client.get.return_value = {"org": _raw_ao(id=7)} + result = self.repo.get_by_id(7) + self.client.get.assert_called_once_with("/v1/org/id/7") + self.assertIsNotNone(result) + self.assertEqual(result.id, 7) + + def test_get_by_id_returns_none_when_not_found(self): + self.client.get.side_effect = F3ApiNotFoundError(404, "Not found") + result = self.repo.get_by_id(999) + self.assertIsNone(result) + + def test_get_by_id_returns_none_when_response_empty(self): + self.client.get.return_value = {} + result = self.repo.get_by_id(1) + self.assertIsNone(result) + + # ------------------------------------------------------------------ + # create + # ------------------------------------------------------------------ + + def test_create_posts_correct_payload(self): + self.client.post.return_value = {"org": _raw_ao(id=20)} + result = self.repo.create( + parent_id=10, + name="New AO", + description="A description", + slack_channel_id="C999", + default_location_id=5, + ) + self.client.post.assert_called_once_with( + "/v1/org", + json={ + "name": "New AO", + "orgType": "ao", + "parentId": 10, + "isActive": True, + "meta": {"slack_channel_id": "C999"}, + "description": "A description", + "defaultLocationId": 5, + "website": "", + "twitter": "", + "facebook": "", + "instagram": "", + }, + ) + self.assertEqual(result.id, 20) + + def test_create_omits_optional_fields_when_none(self): + self.client.post.return_value = {"org": _raw_ao(id=21)} + self.repo.create( + parent_id=10, + name="Minimal AO", + description=None, + slack_channel_id=None, + default_location_id=None, + ) + call_kwargs = self.client.post.call_args[1]["json"] + self.assertNotIn("description", call_kwargs) + self.assertNotIn("defaultLocationId", call_kwargs) + self.assertEqual(call_kwargs["meta"], {}) + + # ------------------------------------------------------------------ + # update + # ------------------------------------------------------------------ + + def test_update_posts_correct_payload(self): + self.repo.update( + ao_id=7, + parent_id=10, + name="Updated AO", + description="New desc", + slack_channel_id="C777", + default_location_id=3, + logo_url="http://example.com/new_logo.png", + ) + self.client.post.assert_called_once_with( + "/v1/org", + json={ + "id": 7, + "name": "Updated AO", + "orgType": "ao", + "parentId": 10, + "isActive": True, + "meta": {"slack_channel_id": "C777"}, + "description": "New desc", + "defaultLocationId": 3, + "logoUrl": "http://example.com/new_logo.png", + "website": "", + "twitter": "", + "facebook": "", + "instagram": "", + }, + ) + + def test_update_without_logo_omits_logo_url(self): + self.repo.update( + ao_id=7, + parent_id=10, + name="Updated AO", + description=None, + slack_channel_id=None, + default_location_id=None, + ) + call_kwargs = self.client.post.call_args[1]["json"] + self.assertNotIn("logoUrl", call_kwargs) + + # ------------------------------------------------------------------ + # delete + # ------------------------------------------------------------------ + + def test_delete_calls_correct_endpoint(self): + self.repo.delete(44) + self.client.delete.assert_called_once_with("/v1/org/delete/44") + + # ------------------------------------------------------------------ + # singleton + # ------------------------------------------------------------------ + + @patch("infrastructure.api_client.ao_repository.get_f3_api_client") + def test_get_api_ao_repository_returns_singleton(self, mock_get_client): + mock_get_client.return_value = MagicMock() + with patch("infrastructure.api_client.ao_repository.ApiAoRepository") as mock_repo_cls: + first_repo = MagicMock() + second_repo = MagicMock() + mock_repo_cls.side_effect = [first_repo, second_repo] + + with patch("infrastructure.api_client.ao_repository._repo", None): + repo_one = get_api_ao_repository() + repo_two = get_api_ao_repository() + + self.assertIs(repo_one, first_repo) + self.assertIs(repo_two, first_repo) + mock_repo_cls.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/infrastructure/api_client/test_client.py b/apps/slackbot/tests/infrastructure/api_client/test_client.py new file mode 100644 index 00000000..7fa73167 --- /dev/null +++ b/apps/slackbot/tests/infrastructure/api_client/test_client.py @@ -0,0 +1,162 @@ +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +import requests + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +from infrastructure.api_client.client import F3ApiClient, get_f3_api_client +from infrastructure.api_client.exceptions import F3ApiAuthError, F3ApiError, F3ApiNotFoundError + + +class F3ApiClientTest(unittest.TestCase): + def _make_response(self, status_code: int = 200, ok: bool = True, json_payload=None, text: str = ""): + response = MagicMock() + response.status_code = status_code + response.ok = ok + response.text = text + if json_payload is None: + response.json.side_effect = ValueError("no json") + else: + response.json.return_value = json_payload + return response + + def test_requires_api_key(self): + with patch.dict(os.environ, {}, clear=True): + with self.assertRaisesRegex(ValueError, "F3_API_KEY"): + F3ApiClient() + + def test_get_uses_base_url_params_and_timeout(self): + with patch("infrastructure.api_client.client.requests.Session") as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.get.return_value = self._make_response(json_payload={"ok": True}) + + with patch.dict( + os.environ, + { + "F3_API_KEY": "test-key", + "F3_API_BASE_URL": "http://api.local", + "F3_API_TIMEOUT_SECONDS": "12.5", + }, + clear=True, + ): + client = F3ApiClient() + result = client.get("/v1/event-tag", params={"pageSize": 1}) + + self.assertEqual(result, {"ok": True}) + mock_session.get.assert_called_once_with( + "http://api.local/v1/event-tag", + timeout=12.5, + params={"pageSize": 1}, + ) + + def test_invalid_timeout_falls_back_to_default(self): + with patch("infrastructure.api_client.client.requests.Session") as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.get.return_value = self._make_response(json_payload={"ok": True}) + + with patch.dict( + os.environ, + { + "F3_API_KEY": "test-key", + "F3_API_TIMEOUT_SECONDS": "not-a-number", + }, + clear=True, + ): + client = F3ApiClient() + client.get("/v1/event-tag") + + self.assertEqual(client._timeout_seconds, 8.0) + mock_session.get.assert_called_once_with( + "https://api.f3nation.com/v1/event-tag", + timeout=8.0, + params=None, + ) + + def test_raises_not_found_error(self): + with patch("infrastructure.api_client.client.requests.Session") as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.get.return_value = self._make_response(status_code=404, ok=False, text="missing") + + with patch.dict(os.environ, {"F3_API_KEY": "test-key"}, clear=True): + client = F3ApiClient() + with self.assertRaises(F3ApiNotFoundError): + client.get("/v1/event-tag/id/1") + + def test_raises_auth_error(self): + with patch("infrastructure.api_client.client.requests.Session") as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.get.return_value = self._make_response(status_code=401, ok=False, text="unauthorized") + + with patch.dict(os.environ, {"F3_API_KEY": "test-key"}, clear=True): + client = F3ApiClient() + with self.assertRaises(F3ApiAuthError): + client.get("/v1/event-tag/id/1") + + def test_raises_generic_api_error(self): + with patch("infrastructure.api_client.client.requests.Session") as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.get.return_value = self._make_response(status_code=500, ok=False, text="server error") + + with patch.dict(os.environ, {"F3_API_KEY": "test-key"}, clear=True): + client = F3ApiClient() + with self.assertRaises(F3ApiError) as context: + client.get("/v1/event-tag/id/1") + + self.assertEqual(context.exception.status_code, 500) + + def test_wraps_network_errors(self): + with patch("infrastructure.api_client.client.requests.Session") as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.get.side_effect = requests.RequestException("network down") + + with patch.dict(os.environ, {"F3_API_KEY": "test-key"}, clear=True): + client = F3ApiClient() + with self.assertRaises(F3ApiError) as context: + client.get("/v1/event-tag") + + self.assertEqual(context.exception.status_code, 0) + + def test_handles_204_and_non_json_success(self): + with patch("infrastructure.api_client.client.requests.Session") as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + + response_204 = self._make_response(status_code=204, ok=True, text="") + response_text = self._make_response(status_code=200, ok=True, json_payload=None, text="ok") + mock_session.delete.return_value = response_204 + mock_session.post.return_value = response_text + + with patch.dict(os.environ, {"F3_API_KEY": "test-key"}, clear=True): + client = F3ApiClient() + delete_result = client.delete("/v1/event-tag/id/1") + post_result = client.post("/v1/event-tag", json={"name": "Tag"}) + + self.assertIsNone(delete_result) + self.assertEqual(post_result, "ok") + + def test_get_f3_api_client_returns_singleton(self): + with patch("infrastructure.api_client.client.F3ApiClient") as mock_client_cls: + first_client = MagicMock() + second_client = MagicMock() + mock_client_cls.side_effect = [first_client, second_client] + + with patch("infrastructure.api_client.client._client", None): + result_one = get_f3_api_client() + result_two = get_f3_api_client() + + self.assertIs(result_one, first_client) + self.assertIs(result_two, first_client) + mock_client_cls.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/infrastructure/api_client/test_event_tag_repository.py b/apps/slackbot/tests/infrastructure/api_client/test_event_tag_repository.py new file mode 100644 index 00000000..dc284ade --- /dev/null +++ b/apps/slackbot/tests/infrastructure/api_client/test_event_tag_repository.py @@ -0,0 +1,130 @@ +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +from infrastructure.api_client.event_tag_repository import ApiEventTagRepository, get_api_event_tag_repository +from infrastructure.api_client.exceptions import F3ApiNotFoundError + + +class ApiEventTagRepositoryTest(unittest.TestCase): + def setUp(self): + self.client = MagicMock() + self.repo = ApiEventTagRepository(self.client) + + def test_get_by_org_filters_to_requested_org(self): + self.client.get.return_value = { + "eventTags": [ + {"id": 1, "name": "Mine", "color": "Red", "specificOrgId": 10, "isActive": True}, + {"id": 2, "name": "Other", "color": "Blue", "specificOrgId": 11, "isActive": True}, + {"id": 3, "name": "Global", "color": "Green", "specificOrgId": None, "isActive": True}, + ] + } + + result = self.repo.get_by_org(10) + + self.client.get.assert_called_once_with("/v1/event-tag", params={"orgIds": [10], "statuses": ["active"]}) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 1) + self.assertEqual(result[0].specific_org_id, 10) + + def test_get_by_org_supports_results_fallback_and_snake_case(self): + self.client.get.return_value = { + "results": [ + { + "id": 99, + "name": "Fallback", + "color": None, + "specific_org_id": 77, + "is_active": True, + "description": "fallback payload", + } + ] + } + + result = self.repo.get_by_org(77) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 99) + self.assertEqual(result[0].specific_org_id, 77) + self.assertTrue(result[0].is_active) + + def test_get_by_org_returns_empty_list_when_payload_has_no_expected_keys(self): + self.client.get.return_value = {"unexpected": []} + + result = self.repo.get_by_org(77) + + self.assertEqual(result, []) + + def test_get_by_id_returns_none_for_not_found(self): + self.client.get.side_effect = F3ApiNotFoundError(404, "not found") + + result = self.repo.get_by_id(123) + + self.assertIsNone(result) + + def test_get_by_id_parses_payload(self): + self.client.get.return_value = { + "eventTag": {"id": 12, "name": "Tag", "color": "Orange", "specificOrgId": 5, "isActive": True} + } + + result = self.repo.get_by_id(12) + + self.client.get.assert_called_once_with("/v1/event-tag/id/12") + self.assertIsNotNone(result) + self.assertEqual(result.id, 12) + self.assertEqual(result.name, "Tag") + + def test_get_by_id_supports_result_fallback(self): + self.client.get.return_value = { + "result": {"id": 42, "name": "FallbackTag", "color": "Gray", "specific_org_id": 7, "is_active": True} + } + + result = self.repo.get_by_id(42) + + self.assertIsNotNone(result) + self.assertEqual(result.id, 42) + self.assertEqual(result.specific_org_id, 7) + + def test_create_posts_expected_payload(self): + self.repo.create("New", "Yellow", 3) + + self.client.post.assert_called_once_with( + "/v1/event-tag", + json={"name": "New", "color": "Yellow", "specificOrgId": 3, "isActive": True}, + ) + + def test_update_posts_expected_payload(self): + self.repo.update(7, "Renamed", "Purple") + + self.client.post.assert_called_once_with( + "/v1/event-tag", + json={"id": 7, "name": "Renamed", "color": "Purple"}, + ) + + def test_delete_calls_expected_endpoint(self): + self.repo.delete(44) + + self.client.delete.assert_called_once_with("/v1/event-tag/id/44") + + @patch("infrastructure.api_client.event_tag_repository.get_f3_api_client") + def test_get_api_event_tag_repository_returns_singleton(self, mock_get_client): + mock_get_client.return_value = MagicMock() + with patch("infrastructure.api_client.event_tag_repository.ApiEventTagRepository") as mock_repo_cls: + first_repo = MagicMock() + second_repo = MagicMock() + mock_repo_cls.side_effect = [first_repo, second_repo] + + with patch("infrastructure.api_client.event_tag_repository._repo", None): + repo_one = get_api_event_tag_repository() + repo_two = get_api_event_tag_repository() + + self.assertIs(repo_one, first_repo) + self.assertIs(repo_two, first_repo) + mock_repo_cls.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/infrastructure/api_client/test_event_type_repository.py b/apps/slackbot/tests/infrastructure/api_client/test_event_type_repository.py new file mode 100644 index 00000000..c7628c9a --- /dev/null +++ b/apps/slackbot/tests/infrastructure/api_client/test_event_type_repository.py @@ -0,0 +1,242 @@ +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +from infrastructure.api_client.event_type_repository import ApiEventTypeRepository, get_api_event_type_repository +from infrastructure.api_client.exceptions import F3ApiNotFoundError + + +class ApiEventTypeRepositoryTest(unittest.TestCase): + def setUp(self): + self.client = MagicMock() + self.repo = ApiEventTypeRepository(self.client) + + # ------------------------------------------------------------------ + # get_by_org + # ------------------------------------------------------------------ + + def test_get_by_org_filters_to_requested_org(self): + self.client.get.return_value = { + "eventTypes": [ + { + "id": 1, + "name": "Bootcamp", + "acronym": "BC", + "eventCategory": "first_f", + "specificOrgId": 10, + "isActive": True, + }, + { + "id": 2, + "name": "Ruck", + "acronym": "RK", + "eventCategory": "first_f", + "specificOrgId": 11, + "isActive": True, + }, + { + "id": 3, + "name": "Global", + "acronym": "GL", + "eventCategory": "second_f", + "specificOrgId": None, + "isActive": True, + }, + ] + } + + result = self.repo.get_by_org(10) + + self.client.get.assert_called_once_with("/v1/event-type", params={"orgIds": [10], "statuses": ["active"]}) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 1) + self.assertEqual(result[0].specific_org_id, 10) + + def test_get_by_org_supports_results_fallback_and_snake_case(self): + self.client.get.return_value = { + "results": [ + { + "id": 99, + "name": "Swim", + "acronym": "SW", + "event_category": "third_f", + "specific_org_id": 77, + "is_active": True, + } + ] + } + + result = self.repo.get_by_org(77) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 99) + self.assertEqual(result[0].event_category, "third_f") + + def test_get_by_org_excludes_global_types(self): + self.client.get.return_value = { + "eventTypes": [ + { + "id": 1, + "name": "Global", + "acronym": "GL", + "eventCategory": "first_f", + "specificOrgId": None, + "isActive": True, + }, + ] + } + + result = self.repo.get_by_org(10) + + self.assertEqual(result, []) + + # ------------------------------------------------------------------ + # get_all_for_org + # ------------------------------------------------------------------ + + def test_get_all_for_org_includes_global_and_org_specific(self): + self.client.get.return_value = { + "eventTypes": [ + { + "id": 1, + "name": "Bootcamp", + "acronym": "BC", + "eventCategory": "first_f", + "specificOrgId": 10, + "isActive": True, + }, + { + "id": 2, + "name": "Other Org", + "acronym": "OO", + "eventCategory": "first_f", + "specificOrgId": 11, + "isActive": True, + }, + { + "id": 3, + "name": "Global", + "acronym": "GL", + "eventCategory": "second_f", + "specificOrgId": None, + "isActive": True, + }, + ] + } + + result = self.repo.get_all_for_org(10) + + self.assertEqual(len(result), 2) + ids = {r.id for r in result} + self.assertIn(1, ids) + self.assertIn(3, ids) + self.assertNotIn(2, ids) + + # ------------------------------------------------------------------ + # get_by_id + # ------------------------------------------------------------------ + + def test_get_by_id_returns_event_type(self): + self.client.get.return_value = { + "eventType": { + "id": 5, + "name": "Yoga", + "acronym": "YG", + "eventCategory": "third_f", + "specificOrgId": 10, + "isActive": True, + } + } + + result = self.repo.get_by_id(5) + + self.client.get.assert_called_once_with("/v1/event-type/id/5") + self.assertIsNotNone(result) + self.assertEqual(result.id, 5) + self.assertEqual(result.name, "Yoga") + + def test_get_by_id_returns_none_on_not_found(self): + self.client.get.side_effect = F3ApiNotFoundError(404, "not found") + + result = self.repo.get_by_id(999) + + self.assertIsNone(result) + + def test_get_by_id_supports_result_fallback_key(self): + self.client.get.return_value = { + "result": { + "id": 7, + "name": "Run", + "acronym": "RN", + "eventCategory": "first_f", + "specificOrgId": None, + "isActive": True, + } + } + + result = self.repo.get_by_id(7) + + self.assertEqual(result.id, 7) + + # ------------------------------------------------------------------ + # create + # ------------------------------------------------------------------ + + def test_create_posts_correct_payload(self): + self.repo.create("Bootcamp", "BC", "first_f", 10) + + self.client.post.assert_called_once_with( + "/v1/event-type", + json={ + "name": "Bootcamp", + "acronym": "BC", + "eventCategory": "first_f", + "specificOrgId": 10, + "isActive": True, + }, + ) + + # ------------------------------------------------------------------ + # update + # ------------------------------------------------------------------ + + def test_update_posts_correct_payload(self): + self.repo.update(42, "Updated Name", "UN", "second_f") + + self.client.post.assert_called_once_with( + "/v1/event-type", + json={"id": 42, "name": "Updated Name", "acronym": "UN", "eventCategory": "second_f"}, + ) + + # ------------------------------------------------------------------ + # delete + # ------------------------------------------------------------------ + + def test_delete_calls_correct_endpoint(self): + self.repo.delete(99) + + self.client.delete.assert_called_once_with("/v1/event-type/id/99") + + # ------------------------------------------------------------------ + # Singleton + # ------------------------------------------------------------------ + + def test_get_api_event_type_repository_returns_same_instance(self): + import infrastructure.api_client.event_type_repository as mod + + original = mod._repo + try: + mod._repo = None + with patch("infrastructure.api_client.event_type_repository.get_f3_api_client", return_value=MagicMock()): + r1 = get_api_event_type_repository() + r2 = get_api_event_type_repository() + self.assertIs(r1, r2) + finally: + mod._repo = original + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/infrastructure/api_client/test_location_repository.py b/apps/slackbot/tests/infrastructure/api_client/test_location_repository.py new file mode 100644 index 00000000..e7f1dea8 --- /dev/null +++ b/apps/slackbot/tests/infrastructure/api_client/test_location_repository.py @@ -0,0 +1,258 @@ +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +from infrastructure.api_client.exceptions import F3ApiNotFoundError +from infrastructure.api_client.location_repository import ApiLocationRepository, get_api_location_repository + + +class ApiLocationRepositoryTest(unittest.TestCase): + def setUp(self): + self.client = MagicMock() + self.repo = ApiLocationRepository(self.client) + + # ------------------------------------------------------------------ + # get_by_org + # ------------------------------------------------------------------ + + def test_get_by_org_returns_locations(self): + self.client.get.return_value = { + "locations": [ + { + "id": 1, + "locationName": "Central Park", + "orgId": 10, + "isActive": True, + }, + { + "id": 2, + "locationName": "City Hall", + "orgId": 10, + "isActive": True, + }, + ] + } + + result = self.repo.get_by_org(10) + + self.client.get.assert_called_once_with("/v1/location", params={"regionIds": [10]}) + self.assertEqual(len(result), 2) + self.assertEqual(result[0].id, 1) + self.assertEqual(result[0].name, "Central Park") + + def test_get_by_org_supports_results_fallback_and_snake_case(self): + self.client.get.return_value = { + "results": [ + { + "id": 99, + "name": "Riverside Park", # snake_case fallback + "org_id": 77, + "is_active": True, + "address_street": "123 Main St", + "address_city": "Springfield", + "address_state": "IL", + } + ] + } + + result = self.repo.get_by_org(77) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 99) + self.assertEqual(result[0].address_street, "123 Main St") + self.assertEqual(result[0].address_city, "Springfield") + + def test_get_by_org_returns_empty_list_when_no_expected_keys(self): + self.client.get.return_value = {"unexpected": []} + + result = self.repo.get_by_org(10) + + self.assertEqual(result, []) + + def test_get_by_org_parses_camel_case_address_fields(self): + self.client.get.return_value = { + "locations": [ + { + "id": 5, + "locationName": "Loc", + "addressStreet": "456 Elm St", + "addressStreet2": "Suite 1", + "addressCity": "Shelbyville", + "addressState": "IL", + "addressZip": "62700", + "addressCountry": "USA", + "latitude": 39.7, + "longitude": -88.5, + "isActive": True, + } + ] + } + + result = self.repo.get_by_org(1) + + loc = result[0] + self.assertEqual(loc.address_street, "456 Elm St") + self.assertEqual(loc.address_street2, "Suite 1") + self.assertEqual(loc.address_city, "Shelbyville") + self.assertEqual(loc.address_state, "IL") + self.assertEqual(loc.address_zip, "62700") + self.assertEqual(loc.address_country, "USA") + self.assertAlmostEqual(loc.latitude, 39.7) + self.assertAlmostEqual(loc.longitude, -88.5) + + # ------------------------------------------------------------------ + # get_by_id + # ------------------------------------------------------------------ + + def test_get_by_id_returns_none_for_not_found(self): + self.client.get.side_effect = F3ApiNotFoundError(404, "not found") + + result = self.repo.get_by_id(123) + + self.assertIsNone(result) + + def test_get_by_id_parses_payload(self): + self.client.get.return_value = { + "location": {"id": 12, "locationName": "Riverside", "orgId": 5, "isActive": True} + } + + result = self.repo.get_by_id(12) + + self.client.get.assert_called_once_with("/v1/location/id/12") + self.assertIsNotNone(result) + self.assertEqual(result.id, 12) + self.assertEqual(result.name, "Riverside") + + def test_get_by_id_supports_result_fallback(self): + self.client.get.return_value = {"result": {"id": 33, "locationName": "Lake Park", "isActive": True}} + + result = self.repo.get_by_id(33) + + self.assertIsNotNone(result) + self.assertEqual(result.id, 33) + + def test_get_by_id_returns_none_when_empty_payload(self): + self.client.get.return_value = {} + + result = self.repo.get_by_id(9) + + self.assertIsNone(result) + + # ------------------------------------------------------------------ + # create + # ------------------------------------------------------------------ + + def test_create_posts_to_api_and_returns_location(self): + self.client.post.return_value = {"location": {"id": 50, "name": "New Park", "orgId": 10, "isActive": True}} + + result = self.repo.create( + name="New Park", + org_id=10, + description=None, + latitude=34.05, + longitude=-118.24, + address_street=None, + address_street2=None, + address_city=None, + address_state=None, + address_zip=None, + address_country=None, + ) + + self.client.post.assert_called_once() + call_json = self.client.post.call_args[1]["json"] + self.assertEqual(call_json["name"], "New Park") + self.assertEqual(call_json["orgId"], 10) + self.assertTrue(call_json["isActive"]) + self.assertAlmostEqual(call_json["latitude"], 34.05) + self.assertEqual(result.id, 50) + + def test_create_omits_none_optional_fields(self): + self.client.post.return_value = {"location": {"id": 51, "locationName": "Slim Park", "isActive": True}} + + self.repo.create( + name="Slim Park", + org_id=5, + description=None, + latitude=None, + longitude=None, + address_street=None, + address_street2=None, + address_city=None, + address_state=None, + address_zip=None, + address_country=None, + ) + + call_json = self.client.post.call_args[1]["json"] + self.assertNotIn("latitude", call_json) + self.assertNotIn("longitude", call_json) + self.assertNotIn("description", call_json) + + # ------------------------------------------------------------------ + # update + # ------------------------------------------------------------------ + + def test_update_posts_correct_payload(self): + self.client.post.return_value = None + + self.repo.update( + location_id=7, + name="Updated Park", + org_id=10, + description="New desc", + latitude=35.0, + longitude=-119.0, + address_street="789 Oak Ave", + address_street2=None, + address_city="Somewhere", + address_state="CA", + address_zip="90000", + address_country="USA", + ) + + call_json = self.client.post.call_args[1]["json"] + self.assertEqual(call_json["id"], 7) + self.assertEqual(call_json["name"], "Updated Park") + self.assertEqual(call_json["orgId"], 10) + self.assertTrue(call_json["isActive"]) + self.assertEqual(call_json["description"], "New desc") + self.assertEqual(call_json["addressStreet"], "789 Oak Ave") + self.assertNotIn("addressStreet2", call_json) + + # ------------------------------------------------------------------ + # delete + # ------------------------------------------------------------------ + + def test_delete_calls_correct_endpoint(self): + self.client.delete.return_value = None + + self.repo.delete(42) + + self.client.delete.assert_called_once_with("/v1/location/delete/42") + + # ------------------------------------------------------------------ + # Singleton + # ------------------------------------------------------------------ + + def test_singleton_reuses_instance(self): + import infrastructure.api_client.location_repository as loc_mod + + original = loc_mod._repo + try: + loc_mod._repo = None + with patch("infrastructure.api_client.location_repository.get_f3_api_client") as mock_client: + mock_client.return_value = MagicMock() + r1 = get_api_location_repository() + r2 = get_api_location_repository() + self.assertIs(r1, r2) + mock_client.assert_called_once() + finally: + loc_mod._repo = original + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/infrastructure/api_client/test_position_repository.py b/apps/slackbot/tests/infrastructure/api_client/test_position_repository.py new file mode 100644 index 00000000..4241ef43 --- /dev/null +++ b/apps/slackbot/tests/infrastructure/api_client/test_position_repository.py @@ -0,0 +1,235 @@ +import os +import sys +import unittest +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +from infrastructure.api_client.exceptions import F3ApiNotFoundError +from infrastructure.api_client.position_repository import ApiPositionRepository, get_api_position_repository + + +def _make_position_raw(id=1, name="President", org_id=10, org_type="region", is_active=True): + return { + "id": id, + "name": name, + "description": "Top leader", + "orgId": org_id, + "orgType": org_type, + "isActive": is_active, + "created": "2024-01-01", + "updated": "2024-01-01", + } + + +def _make_assignment_raw(id=1, name="President", org_id=10, users=None): + return { + "id": id, + "name": name, + "description": None, + "orgId": org_id, + "orgType": "region", + "isActive": True, + "created": "2024-01-01", + "updated": "2024-01-01", + "users": users or [], + } + + +class ApiPositionRepositoryTest(unittest.TestCase): + def setUp(self): + self.client = MagicMock() + self.repo = ApiPositionRepository(self.client) + + # ------------------------------------------------------------------ + # get_by_org + # ------------------------------------------------------------------ + + def test_get_by_org_returns_active_positions(self): + self.client.get.return_value = { + "positions": [ + _make_position_raw(id=1, name="President"), + _make_position_raw(id=2, name="Old Role", is_active=False), + ] + } + + result = self.repo.get_by_org(10) + + self.client.get.assert_called_once_with("/v1/position/org/10", params={"isActive": True}) + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 1) + self.assertEqual(result[0].name, "President") + + def test_get_by_org_falls_back_to_results_key(self): + self.client.get.return_value = {"results": [_make_position_raw(id=5, name="Fallback")]} + + result = self.repo.get_by_org(10) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 5) + + def test_get_by_org_returns_empty_list_on_unknown_key(self): + self.client.get.return_value = {"unexpected": []} + + result = self.repo.get_by_org(10) + + self.assertEqual(result, []) + + def test_get_by_org_handles_snake_case_response(self): + self.client.get.return_value = { + "positions": [ + { + "id": 9, + "name": "Snake", + "description": None, + "org_id": 77, + "org_type": "ao", + "is_active": True, + } + ] + } + + result = self.repo.get_by_org(77) + + self.assertEqual(result[0].org_id, 77) + self.assertEqual(result[0].org_type, "ao") + + # ------------------------------------------------------------------ + # get_assignments + # ------------------------------------------------------------------ + + def test_get_assignments_parses_users(self): + self.client.get.return_value = { + "positions": [ + _make_assignment_raw( + id=1, + users=[{"id": 42, "f3Name": "Dredd", "firstName": "Joe", "lastName": "D", "avatarUrl": None}], + ) + ] + } + + result = self.repo.get_assignments(org_id=10, region_org_id=10) + + self.client.get.assert_called_once_with("/v1/position/assignments/10", params={"regionOrgId": 10}) + self.assertEqual(len(result), 1) + self.assertEqual(len(result[0].users), 1) + self.assertEqual(result[0].users[0].user_id, 42) + self.assertEqual(result[0].users[0].f3_name, "Dredd") + + def test_get_assignments_handles_empty_users(self): + self.client.get.return_value = {"positions": [_make_assignment_raw(id=1, users=[])]} + + result = self.repo.get_assignments(10, 10) + + self.assertEqual(result[0].users, []) + + def test_get_assignments_uses_region_org_id_param(self): + self.client.get.return_value = {"positions": []} + + self.repo.get_assignments(org_id=5, region_org_id=99) + + self.client.get.assert_called_once_with("/v1/position/assignments/5", params={"regionOrgId": 99}) + + # ------------------------------------------------------------------ + # get_by_id + # ------------------------------------------------------------------ + + def test_get_by_id_returns_position(self): + self.client.get.return_value = {"position": _make_position_raw(id=7)} + + result = self.repo.get_by_id(7) + + self.client.get.assert_called_once_with("/v1/position/id/7") + self.assertIsNotNone(result) + self.assertEqual(result.id, 7) + + def test_get_by_id_returns_none_on_not_found(self): + self.client.get.side_effect = F3ApiNotFoundError(404, "not found") + + result = self.repo.get_by_id(999) + + self.assertIsNone(result) + + # ------------------------------------------------------------------ + # create + # ------------------------------------------------------------------ + + def test_create_sends_correct_payload(self): + self.client.post.return_value = {"position": _make_position_raw(id=10, name="VP")} + + result = self.repo.create(name="VP", description="Vice President", org_id=10, org_type="region") + + self.client.post.assert_called_once_with( + "/v1/position", + json={ + "name": "VP", + "description": "Vice President", + "orgId": 10, + "orgType": "region", + "isActive": True, + }, + ) + self.assertEqual(result.id, 10) + + # ------------------------------------------------------------------ + # update + # ------------------------------------------------------------------ + + def test_update_sends_correct_payload(self): + self.client.post.return_value = {"position": _make_position_raw(id=3)} + + self.repo.update(position_id=3, name="Updated", description="New desc") + + self.client.post.assert_called_once_with( + "/v1/position", + json={"id": 3, "name": "Updated", "description": "New desc"}, + ) + + # ------------------------------------------------------------------ + # delete + # ------------------------------------------------------------------ + + def test_delete_calls_correct_endpoint(self): + self.client.delete.return_value = {"positionId": 5} + + self.repo.delete(5) + + self.client.delete.assert_called_once_with("/v1/position/id/5") + + # ------------------------------------------------------------------ + # update_all_assignments + # ------------------------------------------------------------------ + + def test_update_all_assignments_sends_put(self): + self.client.put.return_value = {"success": True, "assignmentCount": 2} + + assignments = [{"positionId": 1, "userIds": [10, 11]}, {"positionId": 2, "userIds": []}] + self.repo.update_all_assignments(org_id=42, assignments=assignments) + + self.client.put.assert_called_once_with( + "/v1/position/assignments", + json={"orgId": 42, "assignments": assignments}, + ) + + # ------------------------------------------------------------------ + # Singleton + # ------------------------------------------------------------------ + + def test_get_api_position_repository_returns_singleton(self): + import infrastructure.api_client.position_repository as mod + + original = mod._repo + mod._repo = None + try: + with patch("infrastructure.api_client.position_repository.get_f3_api_client") as mock_client_fn: + mock_client_fn.return_value = MagicMock() + repo1 = get_api_position_repository() + repo2 = get_api_position_repository() + self.assertIs(repo1, repo2) + mock_client_fn.assert_called_once() + finally: + mod._repo = original + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/infrastructure/api_client/test_series_repository.py b/apps/slackbot/tests/infrastructure/api_client/test_series_repository.py new file mode 100644 index 00000000..14594ef3 --- /dev/null +++ b/apps/slackbot/tests/infrastructure/api_client/test_series_repository.py @@ -0,0 +1,353 @@ +import os +import sys +import unittest +from unittest.mock import MagicMock + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))) + +from infrastructure.api_client.exceptions import F3ApiNotFoundError +from infrastructure.api_client.series_repository import ApiSeriesRepository, _parse_series + + +def _raw_series_list( + id: int = 1, + name: str = "Test Series", + is_active: bool = True, + day_of_week: str = "monday", + start_date: str = "2025-01-06", + start_time: str = "0530", + end_time: str = "0615", + parent_id: int = 10, + region_id: int = 5, + event_type_id: int | None = None, +) -> dict: + """Simulates a record from GET /v1/event (list).""" + raw: dict = { + "id": id, + "name": name, + "isActive": is_active, + "isPrivate": False, + "dayOfWeek": day_of_week, + "startDate": start_date, + "startTime": start_time, + "endTime": end_time, + "highlight": False, + "description": None, + "locationId": None, + "meta": None, + "parents": [{"parentId": parent_id, "parentName": "AO Name"}], + "regions": [{"regionId": region_id, "regionName": "Region Name"}], + "eventTypes": [], + } + if event_type_id is not None: + raw["eventTypes"] = [{"eventTypeId": event_type_id, "eventTypeName": "Bootcamp"}] + return raw + + +def _raw_series_by_id( + id: int = 1, + name: str = "Test Series", + is_active: bool = True, + day_of_week: str = "monday", + start_date: str = "2025-01-06", + start_time: str = "0530", + end_time: str = "0615", + ao_id: int = 10, + region_id: int = 5, + event_type_id: int | None = None, + highlight: bool = False, + meta: dict | None = None, +) -> dict: + """Simulates a record from GET /v1/event/id/{id}.""" + raw: dict = { + "id": id, + "name": name, + "isActive": is_active, + "isPrivate": False, + "dayOfWeek": day_of_week, + "startDate": start_date, + "startTime": start_time, + "endTime": end_time, + "highlight": highlight, + "description": None, + "locationId": None, + "meta": meta, + "aos": [{"aoId": ao_id, "aoName": "AO Name"}], + "regions": [{"regionId": region_id, "regionName": "Region Name"}], + "eventTypes": [], + } + if event_type_id is not None: + raw["eventTypes"] = [{"eventTypeId": event_type_id, "eventTypeName": "Bootcamp"}] + return raw + + +def _raw_crupdate_response( + id: int = 1, + name: str = "Test Series", + org_id: int = 10, + region_id: int = 5, + start_date: str = "2025-01-06", + start_time: str = "0530", + end_time: str = "0615", + day_of_week: str = "monday", +) -> dict: + """Simulates the POST /v1/event (crupdate) response envelope.""" + return { + "event": { + "id": id, + "name": name, + "orgId": org_id, + "regionId": region_id, + "isActive": True, + "isPrivate": False, + "highlight": False, + "startDate": start_date, + "endDate": None, + "startTime": start_time, + "endTime": end_time, + "dayOfWeek": day_of_week, + "description": None, + "locationId": None, + "meta": None, + } + } + + +class ParseSeriesTest(unittest.TestCase): + def test_parse_list_response_uses_parents_for_org_id(self): + raw = _raw_series_list(parent_id=10, region_id=5) + data = _parse_series(raw) + self.assertEqual(data.org_id, 10) + self.assertEqual(data.region_id, 5) + + def test_parse_by_id_response_uses_aos_for_org_id(self): + raw = _raw_series_by_id(ao_id=10, region_id=5) + data = _parse_series(raw) + self.assertEqual(data.org_id, 10) + self.assertEqual(data.region_id, 5) + + def test_parse_crupdate_response_uses_org_id_directly(self): + raw = _raw_crupdate_response(org_id=10, region_id=5)["event"] + data = _parse_series(raw) + self.assertEqual(data.org_id, 10) + + def test_parse_event_type_ids_from_nested_dict(self): + raw = _raw_series_list(event_type_id=42) + data = _parse_series(raw) + self.assertEqual(data.event_type_ids, [42]) + + def test_parse_event_tag_ids_always_empty(self): + raw = _raw_series_list() + data = _parse_series(raw) + self.assertEqual(data.event_tag_ids, []) + + def test_parse_camelcase_fields(self): + raw = _raw_series_list( + start_date="2025-06-02", + start_time="0600", + end_time="0645", + day_of_week="tuesday", + ) + data = _parse_series(raw) + self.assertEqual(data.start_date, "2025-06-02") + self.assertEqual(data.start_time, "0600") + self.assertEqual(data.end_time, "0645") + self.assertEqual(data.day_of_week, "tuesday") + + def test_parse_highlight_from_get_by_id(self): + raw = _raw_series_by_id(highlight=True) + data = _parse_series(raw) + self.assertTrue(data.highlight) + + def test_parse_meta_from_get_by_id(self): + raw = _raw_series_by_id(meta={"do_not_send_auto_preblasts": True}) + data = _parse_series(raw) + self.assertEqual(data.meta, {"do_not_send_auto_preblasts": True}) + + +class ApiSeriesRepositoryTest(unittest.TestCase): + def setUp(self): + self.client = MagicMock() + self.repo = ApiSeriesRepository(self.client) + + # ------------------------------------------------------------------ + # get_by_region + # ------------------------------------------------------------------ + + def test_get_by_region_uses_region_ids(self): + self.client.get.return_value = {"events": [_raw_series_list(id=1), _raw_series_list(id=2)]} + result = self.repo.get_by_region(region_id=5) + self.client.get.assert_called_once_with("/v1/event", params={"regionIds": [5], "statuses": ["active"]}) + self.assertEqual(len(result), 2) + self.assertEqual(result[0].id, 1) + + def test_get_by_region_with_ao_id_uses_ao_ids(self): + self.client.get.return_value = {"events": [_raw_series_list(id=3)]} + result = self.repo.get_by_region(region_id=5, ao_id=10) + self.client.get.assert_called_once_with("/v1/event", params={"aoIds": [10], "statuses": ["active"]}) + self.assertEqual(result[0].id, 3) + + def test_get_by_region_supports_results_fallback(self): + self.client.get.return_value = {"results": [_raw_series_list(id=5)]} + result = self.repo.get_by_region(region_id=5) + self.assertEqual(len(result), 1) + + def test_get_by_region_returns_empty_list_on_no_key(self): + self.client.get.return_value = {"unexpected": []} + result = self.repo.get_by_region(region_id=5) + self.assertEqual(result, []) + + # ------------------------------------------------------------------ + # get_by_id + # ------------------------------------------------------------------ + + def test_get_by_id_returns_series(self): + self.client.get.return_value = {"event": _raw_series_by_id(id=7)} + result = self.repo.get_by_id(7) + self.client.get.assert_called_once_with("/v1/event/id/7") + self.assertIsNotNone(result) + self.assertEqual(result.id, 7) + + def test_get_by_id_returns_none_when_not_found(self): + self.client.get.side_effect = F3ApiNotFoundError(404, "not found") + result = self.repo.get_by_id(999) + self.assertIsNone(result) + + def test_get_by_id_supports_result_fallback(self): + self.client.get.return_value = {"result": _raw_series_by_id(id=8)} + result = self.repo.get_by_id(8) + self.assertIsNotNone(result) + self.assertEqual(result.id, 8) + + # ------------------------------------------------------------------ + # create + # ------------------------------------------------------------------ + + def test_create_posts_to_event_endpoint_without_id(self): + self.client.post.return_value = _raw_crupdate_response(id=99) + result = self.repo.create( + region_id=5, + ao_id=10, + name="New Series", + start_date="2025-01-06", + start_time="0530", + end_time="0615", + day_of_week="monday", + description=None, + location_id=None, + end_date=None, + recurrence_pattern="weekly", + recurrence_interval=1, + index_within_interval=1, + event_type_ids=[42], + event_tag_ids=[], + is_active=True, + is_private=False, + highlight=False, + meta=None, + ) + self.client.post.assert_called_once() + payload = self.client.post.call_args.kwargs["json"] + self.assertNotIn("id", payload) + self.assertEqual(payload["name"], "New Series") + self.assertEqual(payload["regionId"], 5) + self.assertEqual(payload["aoId"], 10) + self.assertTrue(payload["isActive"]) + self.assertEqual(payload["eventTypeIds"], [42]) + self.assertEqual(result.id, 99) + + def test_create_includes_optional_fields_when_set(self): + self.client.post.return_value = _raw_crupdate_response(id=1) + self.repo.create( + region_id=5, + ao_id=10, + name="Series", + start_date="2025-01-06", + start_time="0530", + end_time="0615", + day_of_week="friday", + description="A workout", + location_id=20, + end_date="2026-01-01", + recurrence_pattern="weekly", + recurrence_interval=1, + index_within_interval=1, + event_type_ids=[], + event_tag_ids=[7], + is_active=True, + is_private=True, + highlight=True, + meta={"key": "val"}, + ) + payload = self.client.post.call_args.kwargs["json"] + self.assertEqual(payload["locationId"], 20) + self.assertEqual(payload["endDate"], "2026-01-01") + self.assertEqual(payload["description"], "A workout") + self.assertTrue(payload["isPrivate"]) + self.assertTrue(payload["highlight"]) + self.assertEqual(payload["eventTagIds"], [7]) + self.assertEqual(payload["meta"], {"key": "val"}) + + # ------------------------------------------------------------------ + # update + # ------------------------------------------------------------------ + + def test_update_posts_with_id(self): + self.client.post.return_value = _raw_crupdate_response(id=1) + self.repo.update( + series_id=1, + region_id=5, + ao_id=10, + name="Updated Series", + start_date="2025-01-06", + start_time="0600", + end_time="0645", + description=None, + location_id=None, + end_date=None, + event_type_ids=[42], + event_tag_ids=[], + is_active=True, + is_private=False, + highlight=False, + meta=None, + ) + payload = self.client.post.call_args.kwargs["json"] + self.assertEqual(payload["id"], 1) + self.assertEqual(payload["name"], "Updated Series") + + def test_update_omits_day_of_week(self): + """day_of_week is immutable on edit and should not be sent.""" + self.client.post.return_value = _raw_crupdate_response(id=1) + self.repo.update( + series_id=1, + region_id=5, + ao_id=10, + name="Series", + start_date="2025-01-06", + start_time="0530", + end_time="0615", + description=None, + location_id=None, + end_date=None, + event_type_ids=[], + event_tag_ids=[], + is_active=True, + is_private=False, + highlight=False, + meta=None, + ) + payload = self.client.post.call_args.kwargs["json"] + self.assertNotIn("dayOfWeek", payload) + + # ------------------------------------------------------------------ + # delete + # ------------------------------------------------------------------ + + def test_delete_calls_delete_endpoint(self): + self.repo.delete(7) + self.client.delete.assert_called_once_with("/v1/event/delete/7") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/slackbot/tests/utilities/test_helper_functions.py b/apps/slackbot/tests/utilities/test_helper_functions.py new file mode 100644 index 00000000..6d07db18 --- /dev/null +++ b/apps/slackbot/tests/utilities/test_helper_functions.py @@ -0,0 +1,10 @@ +import os +import sys + +sys.path.append(os.path.join(os.path.dirname(__file__), "..", "..")) +from utilities.helper_functions import safe_get + + +def test_safe_get(): + assert safe_get({"a": {"b": {"c": 1}}}, "a", "b", "c") == 1 + assert safe_get({"a": {"b": {"c": 1}}}, "a", "b", "d") is None diff --git a/apps/slackbot/utilities/__init__.py b/apps/slackbot/utilities/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/slackbot/utilities/bot_logger.py b/apps/slackbot/utilities/bot_logger.py new file mode 100644 index 00000000..2172a1b6 --- /dev/null +++ b/apps/slackbot/utilities/bot_logger.py @@ -0,0 +1,142 @@ +"""Utility for posting bot action log messages to a designated Slack channel. + +Usage:: + + from utilities.bot_logger import post_bot_log + + post_bot_log(client, region_record, "✏️ Event edited: My Event by <@U12345>", logger) + +If ``region_record.bot_log_channel`` is not set, or the configured channel is +inaccessible (archived, deleted, private, etc.), the bot will automatically +create (or locate) a public channel named ``#nation_bot_logs``, join it, +persist the channel ID back to the database, and post there. + +All errors are caught and logged as warnings so that this utility can never +interrupt the feature that called it. +""" + +from __future__ import annotations + +from logging import Logger + +from f3_data_models.models import SlackSpace +from f3_data_models.utils import DbManager +from slack_sdk.errors import SlackApiError +from slack_sdk.web import WebClient + +from utilities.database.orm import SlackSettings + + +def _find_or_create_log_channel(client: WebClient, logger: Logger) -> str | None: + """Return the channel ID for ``#nation_bot_logs``, creating it if needed. + + Returns ``None`` if the channel cannot be found or created. + """ + channel_name = "nation_bot_logs" + + # Try to create the channel first — fastest path. + try: + resp = client.conversations_create(name=channel_name, is_private=False) + channel_id: str = resp["channel"]["id"] + logger.info(f"bot_logger: created #{channel_name} ({channel_id})") + return channel_id + except SlackApiError as exc: + if exc.response.get("error") != "name_taken": + logger.warning(f"bot_logger: conversations_create failed: {exc.response.get('error')}") + return None + # Channel already exists — find it by paginating conversations_list. + logger.info(f"bot_logger: #{channel_name} already exists, searching for it…") + + try: + cursor = None + while True: + kwargs: dict = {"exclude_archived": True, "types": "public_channel", "limit": 200} + if cursor: + kwargs["cursor"] = cursor + list_resp = client.conversations_list(**kwargs) + for channel in list_resp.get("channels", []): + if channel.get("name") == channel_name: + return channel["id"] + cursor = list_resp.get("response_metadata", {}).get("next_cursor") + if not cursor: + break + except SlackApiError as exc: + logger.warning(f"bot_logger: conversations_list failed: {exc.response.get('error')}") + + return None + + +def _ensure_bot_in_channel(client: WebClient, channel_id: str, logger: Logger) -> bool: + """Join the channel if the bot is not already a member. Returns True on success.""" + try: + client.conversations_join(channel=channel_id) + return True + except SlackApiError as exc: + error = exc.response.get("error", "") + # already_in_channel is not actually an error — treat it as success. + if error in ("already_in_channel", "method_not_supported_for_channel_type"): + return True + logger.warning(f"bot_logger: conversations_join failed for {channel_id}: {error}") + return False + + +def _persist_channel(region_record: SlackSettings, channel_id: str, logger: Logger) -> None: + """Save the resolved channel ID back to the database and update the cache.""" + # Import here to avoid circular-import issues at module load time. + from utilities.helper_functions import update_local_region_records # noqa: PLC0415 + + try: + region_record.bot_log_channel = channel_id + DbManager.update_records( + cls=SlackSpace, + filters=[SlackSpace.team_id == region_record.team_id], + fields={SlackSpace.settings: region_record.__dict__}, + ) + update_local_region_records() + logger.info(f"bot_logger: persisted bot_log_channel={channel_id} for team {region_record.team_id}") + except Exception as exc: # pragma: no cover + logger.warning(f"bot_logger: failed to persist bot_log_channel: {exc}") + + +def post_bot_log( + client: WebClient, + region_record: SlackSettings, + text: str, + logger: Logger, +) -> None: + """Post *text* to the workspace's bot log channel. + + The function is entirely fail-safe: any error is swallowed and emitted as a + ``logger.warning`` so that the calling feature is never interrupted. + """ + try: + channel_id: str | None = region_record.bot_log_channel + + if channel_id: + try: + client.chat_postMessage(channel=channel_id, text=text) + return + except SlackApiError as exc: + error = exc.response.get("error", "") + logger.warning( + f"bot_logger: post to configured channel {channel_id} failed ({error}); " + "falling back to #nation_bot_logs" + ) + channel_id = None # Fall through to auto-create logic below. + + # No channel configured (or the configured one is inaccessible). + channel_id = _find_or_create_log_channel(client, logger) + if not channel_id: + logger.warning("bot_logger: could not find or create #nation_bot_logs; skipping log") + return + + if not _ensure_bot_in_channel(client, channel_id, logger): + logger.warning(f"bot_logger: bot cannot join channel {channel_id}; skipping log") + return + + _persist_channel(region_record, channel_id, logger) + + client.chat_postMessage(channel=channel_id, text=text) + + except Exception as exc: # pragma: no cover — last-resort safety net + logger.warning(f"bot_logger: unexpected error posting log: {exc}") diff --git a/apps/slackbot/utilities/builders.py b/apps/slackbot/utilities/builders.py new file mode 100644 index 00000000..eb0078b5 --- /dev/null +++ b/apps/slackbot/utilities/builders.py @@ -0,0 +1,160 @@ +import copy +import time +from logging import Logger +from typing import Any, Dict + +from slack_sdk.models.blocks import ( + ContextBlock, + DividerBlock, + SectionBlock, +) +from slack_sdk.models.blocks.basic_components import PlainTextObject +from slack_sdk.models.views import View +from slack_sdk.web import WebClient + +from utilities import constants +from utilities.database.orm import SlackSettings +from utilities.helper_functions import safe_get +from utilities.slack import actions, forms + +# from pymysql.err import ProgrammingError + + +def submit_modal() -> Dict[str, Any]: + return { + "response_action": "update", + "view": View( + type="modal", + title="Submitting...", + external_id=actions.SUBMIT_MODAL_EXTERNAL_ID, + blocks=[ + SectionBlock( + text=PlainTextObject(text="Submitting your form, please wait... :hourglass_flowing_sand:") + ), + DividerBlock(), + ContextBlock( + elements=[ + PlainTextObject( + text="If this takes longer than 10 seconds, please check back later or contact support." + ) + ] + ), + ], + ), + } + + +def submit_modal_success() -> Dict[str, Any]: + return { + "response_action": "update", + "view": View( + type="modal", + title="Submitting...", + external_id=actions.SUBMIT_MODAL_EXTERNAL_ID, + blocks=[ + SectionBlock( + text=PlainTextObject( + text=":white_check_mark: Your data was saved successfully! You can close this form now." + ) # noqa: E501 + ), + ], + ), + } + + +def update_submit_modal(client: WebClient, logger: Logger, text: str) -> Dict[str, Any]: + view = View( + type="modal", + title="Success!", + external_id=actions.SUBMIT_MODAL_EXTERNAL_ID, + blocks=[ + SectionBlock(text=f":white_check_mark: {text} You can close this form now."), + ], + ) + try: + client.views_update( + external_id=actions.SUBMIT_MODAL_EXTERNAL_ID, + view=view.to_dict(), + ) + except Exception as e: + logger.error(f"Failed to update submit modal: {e}") + + +def add_loading_form(body: dict, client: WebClient, new_or_add: str = "new") -> str: + trigger_id = safe_get(body, "trigger_id") + if safe_get(body, "view", "id"): + loading_form_response = forms.LOADING_FORM.update_modal( + client=client, + view_id=safe_get(body, "view", "id"), + title_text="Loading...", + submit_button_text="None", + callback_id="loading-id", + ) + else: + loading_form_response = forms.LOADING_FORM.post_modal( + client=client, + trigger_id=trigger_id, + title_text="Loading...", + submit_button_text="None", + callback_id="loading-id", + new_or_add=new_or_add, + ) + # wait 0.1 seconds + time.sleep(0.3) + print(f"loading_form_response: {loading_form_response}") + return safe_get(loading_form_response, "view", "id") + + +def add_debug_form(body: dict, client: WebClient, new_or_add: str = "new") -> str: + trigger_id = safe_get(body, "trigger_id") + + form = View( + type="modal", + title="Debug Mode", + external_id=actions.DEBUG_FORM_EXTERNAL_ID, + blocks=[ + SectionBlock(text=":beetle: Debug Mode"), + ], + ) + + view_id = safe_get(body, "view", "id") + view_hash = safe_get(body, "view", "hash") + + if view_id: + # We are already in a modal context (e.g., view_submission). Update that modal by id. + res = client.views_update( + view_id=view_id, + hash=view_hash, + view=form.to_dict(), + ) + else: + # We have a trigger_id (e.g., block_actions/shortcuts). Open a new modal. + res = client.views_open( + trigger_id=trigger_id, + view=form.to_dict(), + ) + + return safe_get(res, "view", "id") + + +def ignore_event(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): + logger.debug("Ignoring event") + + +def send_error_response(body: dict, client: WebClient, error: str) -> None: + error_form = copy.deepcopy(forms.ERROR_FORM) + error_msg = constants.ERROR_FORM_MESSAGE_TEMPLATE.format(error=error) + error_form.set_initial_values({actions.ERROR_FORM_MESSAGE: error_msg}) + + # if safe_get(body, actions.LOADING_ID): + # update_view_id = safe_get(body, actions.LOADING_ID) + # error_form.update_modal( + # client=client, + # view_id=update_view_id, + # title_text="F3 Nation Error", + # submit_button_text="None", + # callback_id="error-id", + # ) + # else: + blocks = [block.as_form_field() for block in error_form.blocks] + client.chat_postMessage(channel=safe_get(body, "user", "id"), text=error, blocks=blocks) diff --git a/apps/slackbot/utilities/constants.py b/apps/slackbot/utilities/constants.py new file mode 100644 index 00000000..3b00547c --- /dev/null +++ b/apps/slackbot/utilities/constants.py @@ -0,0 +1,289 @@ +import os + +import dotenv + +dotenv.load_dotenv() + +SLACK_BOT_TOKEN = "SLACK_BOT_TOKEN" +SLACK_STATE_S3_BUCKET_NAME = "ENV_SLACK_STATE_S3_BUCKET_NAME" +SLACK_INSTALLATION_S3_BUCKET_NAME = "ENV_SLACK_INSTALLATION_S3_BUCKET_NAME" +SLACK_CLIENT_ID = "ENV_SLACK_CLIENT_ID" +SLACK_CLIENT_SECRET = "ENV_SLACK_CLIENT_SECRET" +SLACK_SCOPES = "ENV_SLACK_SCOPES" +PASSWORD_ENCRYPT_KEY = "PASSWORD_ENCRYPT_KEY" +APP_URL = "APP_URL" + +DATABASE_HOST = "DATABASE_HOST" +ADMIN_DATABASE_USER = "ADMIN_DATABASE_USER" +ADMIN_DATABASE_PASSWORD = "ADMIN_DATABASE_PASSWORD" +ADMIN_DATABASE_SCHEMA = "ADMIN_DATABASE_SCHEMA" +PAXMINER_DATABASE_HOST = "PAXMINER_DATABASE_HOST" +PAXMINER_DATABASE_USER = "PAXMINER_DATABASE_USER" +PAXMINER_DATABASE_PASSWORD = "PAXMINER_DATABASE_PASSWORD" +PAXMINER_DATABASE_SCHEMA = "PAXMINER_DATABASE_SCHEMA" +STRAVA_CLIENT_ID = "STRAVA_CLIENT_ID" +STRAVA_CLIENT_SECRET = "STRAVA_CLIENT_SECRET" +LOW_REZ_IMAGE_SIZE = 1000 + +LOCAL_DEVELOPMENT = os.environ.get("LOCAL_DEVELOPMENT", "").lower() in ("1", "true", "yes") +SOCKET_MODE = os.environ.get("SOCKET_MODE", "").lower() in ("1", "true", "yes") +ENABLE_DEBUGGING = os.environ.get("ENABLE_DEBUGGING", "false").lower() == "true" +ALL_USERS_ARE_ADMINS = os.environ.get("ALL_USERS_ARE_ADMINS", "false").lower() == "true" +FILE_BUCKET_PREFIX = os.environ.get("FILE_BUCKET_PREFIX", "f3nation") + +SLACK_STATE_S3_BUCKET_NAME = "ENV_SLACK_STATE_S3_BUCKET_NAME" +SLACK_INSTALLATION_S3_BUCKET_NAME = "ENV_SLACK_INSTALLATION_S3_BUCKET_NAME" +SLACK_CLIENT_ID = "ENV_SLACK_CLIENT_ID" +SLACK_CLIENT_SECRET = "ENV_SLACK_CLIENT_SECRET" +SLACK_SCOPES = "ENV_SLACK_SCOPES" + +CONFIG_DESTINATION_AO = {"name": "The AO Channel", "value": "ao_channel"} +CONFIG_DESTINATION_CURRENT = {"name": "Current Channel", "value": "current_channel"} +CONFIG_DESTINATION_SPECIFIED = {"name": "Specified Channel:", "value": "specified_channel"} + +DEFAULT_BACKBLAST_MOLESKINE_TEMPLATE = { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\nWARMUP:", + "style": {"bold": True}, + }, + { + "type": "text", + "text": " \n", + }, + { + "type": "text", + "text": "THE THANG:", + "style": {"bold": True}, + }, + { + "type": "text", + "text": " \n", + }, + { + "type": "text", + "text": "MARY:", + "style": {"bold": True}, + }, + { + "type": "text", + "text": " \n", + }, + { + "type": "text", + "text": "ANNOUNCEMENTS:", + "style": {"bold": True}, + }, + { + "type": "text", + "text": " \n", + }, + { + "type": "text", + "text": "COT:", + "style": {"bold": True}, + }, + { + "type": "text", + "text": " ", + }, + ], + } + ], +} + +DEFAULT_PREBLAST_MOLESKINE_TEMPLATE = { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\nWHAT:", + "style": {"bold": True}, + }, + { + "type": "text", + "text": " \n", + }, + { + "type": "text", + "text": "WHY: ", + "style": {"bold": True}, + }, + { + "type": "text", + "text": " ", + }, + ], + } + ], +} + +STATE_METADATA = "STATE_METADATA" + +HC_STANDARD_RESPONSE = "{user} has HC'd!" +UNHC_STANDARD_RESPONSE = "{user} has Un-HC'd" + +HC_SNARKY_RESPONSES = [ + "{user} has HC'd and is locked and loaded!", + "{user} just HC'd. The PAX just got better looking.", + "Oh snap, {user} just dropped an HC! Better stretch now.", + "{user} is IN. The gloom shall not be denied.", + "Lock it up boys, {user} just HC'd. No backing out now!", + "The legend himself, {user}, has HC'd. Shield your eyes.", + "{user} just HC'd. Moleskin not required, courage is.", + "HC ALERT: {user} is coming out to play. Hide your weinke.", + "{user} has committed. The Q weeps tears of joy.", + "Someone tell the Q — {user} just HC'd and they mean business.", + "{user} just HC'd. The excuses stop here.", + "An HC from {user}? The mumblechatter is going to be epic.", + "{user} is in. Cancel the search party.", + "HC confirmed for {user}. The fartsack has been defeated.", + "Warning: {user} has HC'd. Expect heavy breathing and questionable form.", + "{user} just HC'd. Bring your own coupon.", + "The gloom beckons, and {user} answered the call. HC logged.", + "Prepare the blacktop, {user} has officially HC'd.", + "That’s an HC from {user}. See you in the gloom, brother.", + "{user} just dropped an HC. Let the beatdown commence.", + "Lock the gates! {user} has HC'd and is ready for the pain.", + "{user} just HC'd. Time to double check the weinke.", + "Boom! {user} is on the board. The PAX grows stronger.", + "{user} HC'd. Get that man a coffee... after the COT.", + "The EH worked! {user} just HC'd.", +] + +UNHC_SNARKY_RESPONSES = [ + "What?!? {user} has Un-HC'd. Guess they don't like you guys.", + "{user} just Un-HC'd. The Q is not crying, you're crying.", + "Breaking news: {user} has abandoned the PAX. Stay strong, men.", + "{user} Un-HC'd. The weinke has been updated accordingly. :cry:", + "Another one bites the dust. {user} is out. #EH them again.", + "{user} Un-HC'd. Their parking spot has been reassigned.", + "Thoughts and prayers for the Q — {user} just Un-HC'd.", + "{user} has Un-HC'd. The downrange spirit is shaken but not stirred.", + "Well well well, {user} Un-HC'd. The coffeeteria has been notified.", + "{user} just Un-HC'd. The gloom misses them already.", + "Oof. {user} just un-HC'd. The fartsack claims another victim.", + "Sad clown alert! {user} has un-HC'd.", + "{user} just un-HC'd. Someone check on their M, they might have chores.", + "Un-HC from {user}. We’ll do an extra burpee in their honor.", + "Alert the EH committee, {user} just un-HC'd.", + "{user} un-HC'd. The gloom will have to wait.", + "Looks like {user} hit snooze. Un-HC recorded.", + "{user} just un-HC'd. Guess those coupons were too heavy.", + "Tragic news: {user} un-HC'd. More respect for the rest of us.", + "{user} un-HC'd. Keep them in your thoughts during the plank jacks.", + "Well, this is awkward. {user} just un-HC'd.", + "{user} backed out. Cue the tiny violin.", + "Un-HC detected from {user}. The Q will remember this.", + "{user} has left the chat (and the beatdown).", + "Someone tag {user} tomorrow morning. The un-HC stings.", +] + +AWS_ACCESS_KEY_ID = "AWS_ACCESS_KEY_ID" +AWS_SECRET_ACCESS_KEY = "AWS_SECRET_ACCESS_KEY" + +WELCOME_MESSAGE_SUFFIX = " Welcome to {region}! We're glad you're here. Please take a moment to introduce yourself and let us know how we can help you get started. We're looking forward to seeing you in the gloom!" # noqa: E501 + +WELCOME_MESSAGE_TEMPLATES = [ + "The man, the myth, the LEGEND, it's {user}!" + WELCOME_MESSAGE_SUFFIX, + "Who's this?!? It's {user}!" + WELCOME_MESSAGE_SUFFIX, + "Hey, it's {user}!" + WELCOME_MESSAGE_SUFFIX, + "Sharkbait, ooh ha ha! It's {user}!" + WELCOME_MESSAGE_SUFFIX, + "Could it be?!? It's {user}!" + WELCOME_MESSAGE_SUFFIX, + "{user} is in the house!" + WELCOME_MESSAGE_SUFFIX, + "Hold the phone, {user} just joined!" + WELCOME_MESSAGE_SUFFIX, + "A wild {user} appears!" + WELCOME_MESSAGE_SUFFIX, + "The fartsack is officially on notice. {user} has arrived!" + WELCOME_MESSAGE_SUFFIX, + "Alert the Qs, {user} is on the roster!" + WELCOME_MESSAGE_SUFFIX, + "Look who decided to step out of the sad clown car! It's {user}!" + WELCOME_MESSAGE_SUFFIX, + "Drop your coupons and welcome {user}!" + WELCOME_MESSAGE_SUFFIX, + "New blood in the brotherhood! Give it up for {user}!" + WELCOME_MESSAGE_SUFFIX, +] + +MAX_HEIC_SIZE = 1000 + +ERROR_FORM_MESSAGE_TEMPLATE = ":warning: Sorry, the following error occurred:\n\n```{error}```" + +PAXMINER_REPORT_DICT = { + "send_pax_charts": "pax_charts", + "send_ao_leaderboard": "ao_leaderboard", + "send_q_charts": "q_charts", + "send_region_leaderboard": "region_leaderboard", + "send_region_stats": "region_stats", +} + +FREQUENCY_OPTIONS = { + "names": ["Week", "Month"], + "values": ["Weekly", "Monthly"], +} +INTERVAL_OPTIONS = { + "names": ["Every", "Every Other", "Every Third", "Every Fourth", "Every Fifth"], + "values": ["1", "2", "3", "4", "5"], +} +WEEK_INDEX_OPTIONS = { + "names": ["1st", "2nd", "3rd", "4th", "5th", "Last"], + "values": ["1", "2", "3", "4", "5", "0"], +} + +ORG_TYPES = { + "AO": 1, + "Region": 2, + "Area": 3, + "Sector": 4, +} + +S3_IMAGE_URL = "https://slackblast-images.s3.amazonaws.com/{image_name}" +GCP_IMAGE_URL = "https://storage.googleapis.com/{bucket}/{image_name}" + +# Define colors for event tags +# first is background color, second is text color +EVENT_TAG_COLORS = { + "Closed": ("#404040", "#888888"), # Dark gray with muted text for closed events + "Red": ("#FF0000", "#FFFFFF"), + "Orange": ("#FFA500", "#FFFFFF"), + "Yellow": ("#FFFF00", "#000000"), + "Green": ("#008000", "#FFFFFF"), + "Blue": ("#0000FF", "#FFFFFF"), + "Purple": ("#800080", "#FFFFFF"), + "Pink": ("#FFC0CB", "#000000"), + "Black": ("#000000", "#FFFFFF"), + "White": ("#FFFFFF", "#000000"), + "Gray": ("#808080", "#FFFFFF"), + "Brown": ("#A52A2A", "#FFFFFF"), + "Cyan": ("#00FFFF", "#000000"), + "Magenta": ("#FF00FF", "#000000"), + "Lime": ("#00FF00", "#000000"), + "Teal": ("#008080", "#FFFFFF"), + "Indigo": ("#4B0082", "#FFFFFF"), + "Maroon": ("#800000", "#FFFFFF"), + "Navy": ("#000080", "#FFFFFF"), + "Olive": ("#808000", "#FFFFFF"), + "Silver": ("#C0C0C0", "#000000"), + "Sky": ("#87CEEB", "#000000"), + "Gold": ("#FFD700", "#000000"), + "Coral": ("#FF7F50", "#000000"), + "Salmon": ("#FA8072", "#000000"), + "Turquoise": ("#40E0D0", "#000000"), + "Lavender": ("#E6E6FA", "#000000"), + "Beige": ("#F5F5DC", "#000000"), + "Mint": ("#98FF98", "#000000"), + "Peach": ("#FFDAB9", "#000000"), +} + +ALL_PERMISSIONS = "All" +PERMISSIONS = { + ALL_PERMISSIONS: "All", +} + +ACHIEVEMENTS_ALPHA_TESTING_ORG_IDS = [ + int(org_id) for org_id in os.environ.get("ACHIEVEMENTS_ALPHA_TESTING_ORG_IDS", "").split(",") if org_id +] +ACHIEVEMENT_AWARD_HOUR_CST = int(os.environ.get("ACHIEVEMENT_AWARD_HOUR_CST", 9)) diff --git a/apps/slackbot/utilities/database/__init__.py b/apps/slackbot/utilities/database/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/slackbot/utilities/database/orm/__init__.py b/apps/slackbot/utilities/database/orm/__init__.py new file mode 100644 index 00000000..4d44d80e --- /dev/null +++ b/apps/slackbot/utilities/database/orm/__init__.py @@ -0,0 +1,80 @@ +from dataclasses import dataclass +from typing import Any, Optional + + +@dataclass +class SlackSettings: + team_id: str + db_id: Optional[int] = None + workspace_name: Optional[str] = None + bot_token: Optional[str] = None + email_enabled: Optional[int] = None + email_server: Optional[str] = None + email_server_port: Optional[int] = None + email_user: Optional[str] = None + email_password: Optional[str] = None + email_to: Optional[str] = None + email_option_show: Optional[int] = None + postie_format: Optional[int] = None + editing_locked: Optional[int] = None + default_backblast_destination: Optional[str] = None + backblast_destination_channel: Optional[str] = None + default_preblast_destination: Optional[str] = None + preblast_destination_channel: Optional[str] = None + backblast_moleskin_template: Optional[dict[str, Any]] = None + preblast_moleskin_template: Optional[dict[str, Any]] = None + strava_enabled: Optional[int] = None + custom_fields: Optional[dict[str, Any]] = None + welcome_dm_enable: Optional[int] = None + welcome_dm_template: Optional[dict[str, Any]] = None + welcome_channel_enable: Optional[int] = None + welcome_channel: Optional[str] = None + send_achievements: Optional[int] = None + send_aoq_reports: Optional[int] = None + achievement_channel: Optional[str] = None + achievement_send_option: Optional[str] = None # Options: "post_individually", "post_summary", "send_in_dms_only" + default_siteq: Optional[str] = None + NO_POST_THRESHOLD: Optional[int] = None + REMINDER_WEEKS: Optional[int] = None + HOME_AO_CAPTURE: Optional[int] = None + NO_Q_THRESHOLD_WEEKS: Optional[int] = None + NO_Q_THRESHOLD_POSTS: Optional[int] = None + org_id: Optional[int] = ( + None # NOTE: down the road, we may not want this here, for example if we want a slack space to be associated with multiple orgs # noqa + ) + calendar_image_current: Optional[str] = None + calendar_image_next: Optional[str] = None + preblast_reminder_days: Optional[int] = None + backblast_reminder_days: Optional[int] = None + special_events_enabled: Optional[int] = None + special_events_channel: Optional[str] = None + special_events_post_days: Optional[int] = None + canvas_channel: Optional[str] = None + paxminer_schema: Optional[str] = None + calendar_group_by_option: Optional[str] = None + send_q_lineups: Optional[bool] = None + send_q_lineups_method: Optional[str] = None + send_q_lineups_channel: Optional[str] = None + send_q_lineups_day: Optional[int] = None + send_q_lineups_hour_cst: Optional[int] = None + migration_date: Optional[str] = None + reporting_region_leaderboard_enabled: Optional[bool] = None + reporting_region_channel: Optional[str] = None + reporting_region_monthly_summary_enabled: Optional[bool] = None + reporting_ao_leaderboard_enabled: Optional[bool] = None + q_image_posting_enabled: Optional[bool] = None + q_image_posting_channel: Optional[str] = None + q_image_posting_ts: Optional[str] = None + reporting_ao_monthly_summary_enabled: Optional[bool] = None + automated_preblast_option: Optional[str] = None + automated_preblast_hour_cst: Optional[int] = None + scheduled_preblast_hour_cst: Optional[int] = None + preblast_reminder_hour_cst: Optional[int] = None + hc_announce_option: Optional[str] = None + hc_announce_targets: Optional[str] = None + downrange_invite_link: Optional[str] = None + downrange_invite_sharing: Optional[str] = None # "proactive" or "request_only" + downrange_channel_posting: Optional[str] = None # "off" or "enabled" + downrange_channel: Optional[str] = None + open_event_color: Optional[str] = None + bot_log_channel: Optional[str] = None diff --git a/apps/slackbot/utilities/database/orm/views.py b/apps/slackbot/utilities/database/orm/views.py new file mode 100644 index 00000000..e93477c2 --- /dev/null +++ b/apps/slackbot/utilities/database/orm/views.py @@ -0,0 +1,112 @@ +from datetime import date, datetime +from typing import Any, Dict, List, Optional + +from sqlalchemy import BigInteger, Boolean, DateTime, Float, Integer +from sqlalchemy.dialects.postgresql import ARRAY, JSONB, VARCHAR +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + __abstract__ = True + + +class EventInstanceExpanded(Base): + """ + Read-only ORM mapping for the materialized view `event_instance_expanded`. + + This view expands each event instance with series, org hierarchy, location, + aggregated type/tag indicators, and arrays of names. It is intended for + querying only and should not be used for inserts/updates. + """ + + __tablename__ = "event_instance_expanded" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + org_id: Mapped[int] = mapped_column(Integer) + location_id: Mapped[Optional[int]] = mapped_column(Integer) + series_id: Mapped[Optional[int]] = mapped_column(Integer) + highlight: Mapped[bool] = mapped_column(Boolean) + start_date: Mapped[date] + end_date: Mapped[Optional[date]] + start_time: Mapped[Optional[str]] + end_time: Mapped[Optional[str]] + name: Mapped[str] + description: Mapped[Optional[str]] + pax_count: Mapped[Optional[int]] + fng_count: Mapped[Optional[int]] + preblast: Mapped[Optional[str]] + backblast: Mapped[Optional[str]] + meta: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSONB) + created: Mapped[datetime] = mapped_column(DateTime) + updated: Mapped[datetime] = mapped_column(DateTime) + + series_name: Mapped[Optional[str]] + series_description: Mapped[Optional[str]] + + ao_org_id: Mapped[Optional[int]] = mapped_column(Integer) + ao_name: Mapped[Optional[str]] + ao_description: Mapped[Optional[str]] + ao_logo_url: Mapped[Optional[str]] + ao_website: Mapped[Optional[str]] + ao_meta: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSONB) + + region_org_id: Mapped[Optional[int]] = mapped_column(Integer) + region_name: Mapped[Optional[str]] + region_description: Mapped[Optional[str]] + region_logo_url: Mapped[Optional[str]] + region_website: Mapped[Optional[str]] + region_meta: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSONB) + + area_org_id: Mapped[Optional[int]] = mapped_column(Integer) + area_name: Mapped[Optional[str]] + sector_org_id: Mapped[Optional[int]] = mapped_column(Integer) + sector_name: Mapped[Optional[str]] + + location_name: Mapped[Optional[str]] + location_description: Mapped[Optional[str]] + location_latitude: Mapped[Optional[float]] = mapped_column(Float) + location_longitude: Mapped[Optional[float]] = mapped_column(Float) + + bootcamp_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + run_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + ruck_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + first_f_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + second_f_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + third_f_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + + pre_workout_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + off_the_books_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + vq_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + convergence_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + + all_types: Mapped[Optional[List[str]]] = mapped_column(ARRAY(VARCHAR)) + all_tags: Mapped[Optional[List[str]]] = mapped_column(ARRAY(VARCHAR)) + + +class EventAttendance(Base): + """ + Read-only ORM mapping for the materialized view `attendance_expanded`. + + This view expands each event instance with series, org hierarchy, location, + aggregated type/tag indicators, and arrays of names. It is intended for + querying only and should not be used for inserts/updates. + """ + + __tablename__ = "attendance_expanded" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer) + event_instance_id: Mapped[int] = mapped_column(Integer) + attendance_meta: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSONB, nullable=True) + created: Mapped[datetime] = mapped_column(DateTime) + updated: Mapped[datetime] = mapped_column(DateTime) + q_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + coq_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + f3_name: Mapped[Optional[str]] = mapped_column(VARCHAR) + last_name: Mapped[Optional[str]] = mapped_column(VARCHAR) + email: Mapped[Optional[str]] = mapped_column(VARCHAR) + home_region_id: Mapped[Optional[int]] = mapped_column(Integer) + home_region_name: Mapped[Optional[str]] = mapped_column(VARCHAR) + avatar_url: Mapped[Optional[str]] = mapped_column(VARCHAR) + user_status: Mapped[Optional[str]] = mapped_column(VARCHAR) + start_date: Mapped[Optional[date]] = mapped_column(DateTime) diff --git a/apps/slackbot/utilities/database/special_queries.py b/apps/slackbot/utilities/database/special_queries.py new file mode 100644 index 00000000..2fdaf573 --- /dev/null +++ b/apps/slackbot/utilities/database/special_queries.py @@ -0,0 +1,555 @@ +from dataclasses import dataclass +from typing import Any, List + +from f3_data_models.models import ( + Attendance, + Attendance_x_AttendanceType, + Event, + EventInstance, + EventTag, + EventType, + EventType_x_EventInstance, + Location, + Org, + Org_Type, + Permission, + Position, + Position_x_Org_x_User, + Role, + Role_x_Permission, + Role_x_User_x_Org, + SlackUser, + User, +) +from f3_data_models.utils import _joinedloads, get_session +from sqlalchemy import and_, case, func, or_, select +from sqlalchemy.orm import joinedload + +from utilities.constants import ALL_PERMISSIONS, PERMISSIONS + + +@dataclass +class CalendarHomeQuery: + event: EventInstance + org: Org + event_types: List[EventType] + planned_qs: str = None + user_attending: int = None + user_q: int = None + series: Event = None + + +def home_schedule_query( + user_id: int, filters: list, limit: int = 45, open_q_only: bool = False, only_users_events: bool = False +) -> list[CalendarHomeQuery]: + session = get_session() + + # LOGIC CHECK: + # If we are filtering BY attendance data (open_q or user_events), + # we cannot optimize using "Limit-First" because the Limit depends on the aggregation. + # We only use the optimization for the standard schedule view. + use_optimization = not (open_q_only or only_users_events) + + if use_optimization: + # --- OPTIMIZED PATH (Limit First) --- + + # 1. The Scout: Find the IDs of the 45 events we want to show FIRST. + # We apply all Event/Org filters here. + candidate_ids_stmt = ( + select(EventInstance.id) + .join(Org, Org.id == EventInstance.org_id) + # Add other necessary joins for filtering/sorting if they are in *filters + # Assuming 'filters' might reference EventType or others, ensure those joins exist: + .join(EventType_x_EventInstance, EventType_x_EventInstance.event_instance_id == EventInstance.id) + .join(EventType, EventType.id == EventType_x_EventInstance.event_type_id) + .filter(*filters) + .order_by(EventInstance.start_date, EventInstance.id, Org.name, EventInstance.start_time) + .limit(limit) + ) + + # Turn this statement into a Common Table Expression (CTE) + # This tells Postgres: "Run this small query first and hold the results." + matches_cte = candidate_ids_stmt.cte("matches_cte") + + # 2. The Subquery: Calculate attendance ONLY for the IDs in the CTE. + # Notice we do NOT apply *filters here. We only join on the CTE. + subquery = ( + select( + Attendance.event_instance_id, + func.string_agg( + case( + ( + and_(Attendance.is_planned, Attendance_x_AttendanceType.attendance_type_id.in_([2, 3])), + User.f3_name, + ), + else_=None, + ), + ",", + ).label("planned_qs"), + func.max(case((Attendance.user_id == user_id, 1), else_=0)).label("user_attending"), + func.max( + case( + ( + and_( + Attendance.user_id == user_id, + Attendance_x_AttendanceType.attendance_type_id.in_([2, 3]), + ), + 1, + ), + else_=0, + ) + ).label("user_q"), + ) + .select_from(Attendance) + .join(User, User.id == Attendance.user_id) + .join(Attendance_x_AttendanceType, Attendance.id == Attendance_x_AttendanceType.attendance_id) + # CRITICAL OPTIMIZATION: Inner join to the CTE. + # This forces the DB to only look at attendance for our 45 "winner" events. + .join(matches_cte, matches_cte.c.id == Attendance.event_instance_id) + .group_by(Attendance.event_instance_id) + .alias() + ) + + # 3. Main Query: Select the details, joining the CTE to ensure we keep our Limit/Sort + query = ( + select( + EventInstance, + Org, + EventType, + subquery.c.planned_qs, + subquery.c.user_attending, + subquery.c.user_q, + Event, + ) + .select_from(matches_cte) # Start from our list of 45 IDs + .join(EventInstance, EventInstance.id == matches_cte.c.id) # Get the full Event object + .join(Org, Org.id == EventInstance.org_id) + .join(EventType_x_EventInstance, EventType_x_EventInstance.event_instance_id == EventInstance.id) + .join(EventType, EventType.id == EventType_x_EventInstance.event_type_id) + .outerjoin(subquery, subquery.c.event_instance_id == EventInstance.id) + .outerjoin(Event, Event.id == EventInstance.series_id) + # We must re-apply the order_by to ensure final output order + .order_by(EventInstance.start_date, EventInstance.id, Org.name, EventInstance.start_time) + ) + + else: + # --- LEGACY PATH (For Open Q / User Events) --- + # This is your original logic, kept for when we need to filter BY the calculated fields. + + subquery = ( + select( + Attendance.event_instance_id, + func.string_agg( + case( + ( + and_(Attendance.is_planned, Attendance_x_AttendanceType.attendance_type_id.in_([2, 3])), + User.f3_name, + ), + else_=None, + ), + ",", + ).label("planned_qs"), + func.max(case((Attendance.user_id == user_id, 1), else_=0)).label("user_attending"), + func.max( + case( + ( + and_( + Attendance.user_id == user_id, + Attendance_x_AttendanceType.attendance_type_id.in_([2, 3]), + ), + 1, + ), + else_=0, + ) + ).label("user_q"), + ) + .select_from(Attendance) + .join(User, User.id == Attendance.user_id) + .join(Attendance_x_AttendanceType, Attendance.id == Attendance_x_AttendanceType.attendance_id) + .join(EventInstance, EventInstance.id == Attendance.event_instance_id) + # We still keep these joins to ensure the subquery filters correctly + .join(Org, Org.id == EventInstance.org_id) + .filter(*filters) # Original heavy filtering + .group_by(Attendance.event_instance_id) + .alias() + ) + + # Apply the specific dynamic filters to the alias columns + final_filters = list(filters) # Copy to avoid modifying the input list + if open_q_only: + final_filters.append(subquery.c.planned_qs.is_(None)) + if only_users_events: + final_filters.append(subquery.c.user_attending == 1) + + query = ( + select( + EventInstance, + Org, + EventType, + subquery.c.planned_qs, + subquery.c.user_attending, + subquery.c.user_q, + Event, + ) + .join(Org, Org.id == EventInstance.org_id) + .join(EventType_x_EventInstance, EventType_x_EventInstance.event_instance_id == EventInstance.id) + .join(EventType, EventType.id == EventType_x_EventInstance.event_type_id) + .outerjoin(subquery, subquery.c.event_instance_id == EventInstance.id) + .outerjoin(Event, Event.id == EventInstance.series_id) + .filter(*final_filters) + .order_by(EventInstance.start_date, EventInstance.id, Org.name, EventInstance.start_time) + .limit(limit) + ) + + # --- EXECUTION --- + results = session.execute(query).all() + + # Turn EventType into a list of EventType objects for each Event.id + event_types = {} + for r in results: + event_types.setdefault(r[0].id, []).append(r[2]) + + # Turn the results into a list of CalendarHomeQuery objects + output = [] + + for r in results: + output.append( + CalendarHomeQuery( + event=r[0], + org=r[1], + event_types=event_types.get(r[0].id, []), + planned_qs=r[3], + user_attending=r[4], + user_q=r[5], + series=r[6], + ) + ) + session.close() + return output + + +@dataclass +class EventExtended: + event: EventInstance + org: Org + event_types: List[EventType] + location: Location + event_tags: List[EventTag] + org_slack_id: str + + +@dataclass +class AttendanceExtended: + attendance: Attendance + user: User + slack_user: SlackUser + + +def event_attendance_query(attendance_filter: List[Any] = None, event_filter: List[Any] = None) -> List[EventInstance]: + with get_session() as session: + attendance_subquery = ( + select(Attendance.event_instance_id.distinct().label("event_instance_id")) + .options(joinedload(Attendance.attendance_types)) + .filter(*(attendance_filter or [])) + .alias() + ) + query = ( + select(EventInstance) + .join(attendance_subquery, attendance_subquery.c.event_instance_id == EventInstance.id) + .filter(*(event_filter or [])) + .order_by(EventInstance.start_date, EventInstance.start_time) + ) + query = _joinedloads(EventInstance, query, "all") + event_records = session.scalars(query).unique().all() + return event_records + + +def event_instances_without_attendance_types( + *, + excluded_attendance_type_ids: list[int], + event_filter: List[Any] = None, + limit: int = 20, +) -> List[EventInstance]: + """ + Returns EventInstances where there are NO Attendance records with any of the given attendance type IDs. + This includes events with zero Attendance rows. + """ + with get_session() as session: + # Correlated subquery: TRUE if the event has any of the excluded attendance types + has_excluded_type = ( + select(Attendance.id) + .join( + Attendance_x_AttendanceType, + Attendance_x_AttendanceType.attendance_id == Attendance.id, + ) + .where( + Attendance_x_AttendanceType.attendance_type_id.in_(excluded_attendance_type_ids), + Attendance.event_instance_id == EventInstance.id, + ) + .correlate(EventInstance) + .exists() + ) + + # NOT EXISTS: keep only events without those attendance types + query = ( + select(EventInstance) + .filter(~has_excluded_type) + .filter(*(event_filter or [])) + .order_by(EventInstance.start_date, EventInstance.start_time) + .limit(limit) + .options(joinedload(EventInstance.org), joinedload(EventInstance.event_types)) + ) + return session.scalars(query).unique().all() + + +def get_user_permission_list(user_id: int, org_id: int) -> list[Permission]: + with get_session() as session: + query = ( + session.query(Permission) + .join(Role_x_Permission, Role_x_Permission.permission_id == Permission.id) + .join(Role, Role.id == Role_x_Permission.role_id) + .join(Role_x_User_x_Org, Role_x_User_x_Org.role_id == Role.id) + .filter(Role_x_User_x_Org.user_id == user_id, Role_x_User_x_Org.org_id == org_id) + ) + return query.all() + + +def get_admin_users_list(org_id: int, slack_team_id: str) -> list[SlackUser]: + with get_session() as session: + query = ( + session.query(SlackUser) + .join(Role_x_User_x_Org, Role_x_User_x_Org.user_id == SlackUser.user_id) + .join(Role, Role.id == Role_x_User_x_Org.role_id) + .join(Role_x_Permission, Role_x_Permission.role_id == Role.id) + .join(Permission, Permission.id == Role_x_Permission.permission_id) + .filter( + Permission.name == PERMISSIONS[ALL_PERMISSIONS], + Role_x_User_x_Org.org_id == org_id, + SlackUser.slack_team_id == slack_team_id, + ) + ) + return query.all() + + +@dataclass +class PositionExtended: + position: Position + slack_users: List[SlackUser] + + +def get_position_users(org_id: int, region_org_id: int, slack_team_id: str) -> List[PositionExtended]: + org_type_level = Org_Type.region if region_org_id == org_id else Org_Type.ao + with get_session() as session: + query = ( + session.query(Position, SlackUser) + .select_from(Position) + .join( + Position_x_Org_x_User, + and_(Position_x_Org_x_User.position_id == Position.id, Position_x_Org_x_User.org_id == org_id), + isouter=True, + ) + .join(User, User.id == Position_x_Org_x_User.user_id, isouter=True) + .join(SlackUser, and_(SlackUser.user_id == User.id, SlackUser.slack_team_id == slack_team_id), isouter=True) + .filter( + and_( + or_(Position.org_type == org_type_level, Position.org_type.is_(None)), + or_(Position.org_id == region_org_id, Position.org_id.is_(None)), + Position.is_active, + ) + ) + .order_by(Position.id) + ) + positions = {} + for position, slack_user in query.all(): + positions.setdefault(position, []).append(slack_user) + + output = [] + for position, slack_users in positions.items(): + output.append(PositionExtended(position=position, slack_users=slack_users)) + + return output + + +def get_aoq_users(region_org_id: int) -> List[User]: + with get_session() as session: + query = ( + session.query(User) + .join(Position_x_Org_x_User, Position_x_Org_x_User.user_id == User.id) + .join(Position, Position.id == Position_x_Org_x_User.position_id) + .join(Org, Org.id == Position_x_Org_x_User.org_id) + .filter( + Position.name == "Site Q", + Org.parent_id == region_org_id, + ) + ) + return query.all() + + +def get_admin_users(org_id: int, slack_team_id: str) -> List[tuple[User, SlackUser]]: + with get_session() as session: + query = ( + session.query(User, SlackUser) + .join(Role_x_User_x_Org, and_(Role_x_User_x_Org.user_id == User.id, Role_x_User_x_Org.org_id == org_id)) + .join(Role, and_(Role.id == Role_x_User_x_Org.role_id, Role.name == "admin")) + .join(SlackUser, and_(SlackUser.user_id == User.id, SlackUser.slack_team_id == slack_team_id), isouter=True) + ) + return query.all() + + +def make_user_admin(org_id: int, user_id: int) -> None: + with get_session() as session: + # Check if the user is already an admin + existing_admin = ( + session.query(Role_x_User_x_Org) + .filter(Role_x_User_x_Org.user_id == user_id, Role_x_User_x_Org.org_id == org_id) + .join(Role, Role.id == Role_x_User_x_Org.role_id) + .filter(Role.name == "admin") + .first() + ) + if existing_admin: + return # User is already an admin + + # Create a new admin role if it doesn't exist + admin_role = session.query(Role).filter(Role.name == "admin").first() + if not admin_role: + admin_role = Role(name="admin") + session.add(admin_role) + session.commit() + + # Assign the admin role to the user + new_admin = Role_x_User_x_Org(user_id=user_id, org_id=org_id, role_id=admin_role.id) + session.add(new_admin) + session.commit() + + +@dataclass +class MissingBackblastQuery: + event: EventInstance + org: Org + event_types: List[EventType] + q_slack_ids: List[str] + site_q_slack_ids: List[str] + + +def get_site_q_slack_ids_by_ao(ao_org_ids: List[int], slack_team_id: str) -> dict: + """Returns a dict mapping org_id -> list of site Q Slack IDs for the given AO org IDs.""" + if not ao_org_ids: + return {} + with get_session() as session: + rows = ( + session.query(Position_x_Org_x_User.org_id, SlackUser.slack_id) + .join(Position, Position.id == Position_x_Org_x_User.position_id) + .join(User, User.id == Position_x_Org_x_User.user_id) + .join(SlackUser, and_(SlackUser.user_id == User.id, SlackUser.slack_team_id == slack_team_id)) + .filter( + Position.name == "Site Q", + Position_x_Org_x_User.org_id.in_(ao_org_ids), + ) + .all() + ) + result: dict = {} + for org_id, slack_id in rows: + result.setdefault(org_id, []).append(slack_id) + return result + + +def missing_backblasts_query( + region_org_id: int, + slack_team_id: str, + org_ids: List[int] = None, + limit: int = 50, +) -> List[MissingBackblastQuery]: + """Returns EventInstances from the last 60 days that are active, have no pax recorded, + and have not been admin-dismissed.""" + import datetime + + sixty_days_ago = datetime.date.today() - datetime.timedelta(days=60) + today = datetime.date.today() + + with get_session() as session: + filters = [ + EventInstance.is_active, + EventInstance.pax_count.is_(None), + EventInstance.start_date >= sixty_days_ago, + EventInstance.start_date <= today, + or_( + EventInstance.org_id == region_org_id, + Org.parent_id == region_org_id, + ), + or_( + EventInstance.meta.is_(None), + func.coalesce( + EventInstance.meta["backblast_admin_dismissed"].as_boolean(), + False, + ).is_(False), + ), + ] + if org_ids: + filters.append(EventInstance.org_id.in_(org_ids)) + + query = ( + select(EventInstance, Org, EventType) + .join(Org, Org.id == EventInstance.org_id) + .join(EventType_x_EventInstance, EventType_x_EventInstance.event_instance_id == EventInstance.id) + .join(EventType, EventType.id == EventType_x_EventInstance.event_type_id) + .filter(*filters) + .order_by(EventInstance.start_date, EventInstance.id) + .limit(limit) + ) + results = session.execute(query).all() + + # Group event types by event id + event_types_map: dict = {} + for r in results: + event_types_map.setdefault(r[0].id, []).append(r[2]) + + # Collect unique (event, org) pairs preserving order + seen = set() + events_orgs = [] + for r in results: + if r[0].id not in seen: + seen.add(r[0].id) + events_orgs.append((r[0], r[1])) + + if not events_orgs: + return [] + + # Fetch planned Q attendance + slack IDs for these events in one query + event_ids = [e.id for e, _ in events_orgs] + with get_session() as session: + q_rows = ( + session.query(Attendance.event_instance_id, SlackUser.slack_id) + .join( + Attendance_x_AttendanceType, + and_( + Attendance_x_AttendanceType.attendance_id == Attendance.id, + Attendance_x_AttendanceType.attendance_type_id.in_([2, 3]), + ), + ) + .join(User, User.id == Attendance.user_id) + .join(SlackUser, and_(SlackUser.user_id == User.id, SlackUser.slack_team_id == slack_team_id)) + .filter( + Attendance.event_instance_id.in_(event_ids), + Attendance.is_planned, + ) + .all() + ) + q_slack_map: dict = {} + for event_id, slack_id in q_rows: + q_slack_map.setdefault(event_id, []).append(slack_id) + + ao_org_ids = list({e.org_id for e, _ in events_orgs}) + site_q_map = get_site_q_slack_ids_by_ao(ao_org_ids, slack_team_id) + + output = [] + for event, org in events_orgs: + output.append( + MissingBackblastQuery( + event=event, + org=org, + event_types=event_types_map.get(event.id, []), + q_slack_ids=q_slack_map.get(event.id, []), + site_q_slack_ids=site_q_map.get(event.org_id, []), + ) + ) + return output diff --git a/apps/slackbot/utilities/default_help.json b/apps/slackbot/utilities/default_help.json new file mode 100644 index 00000000..de039867 --- /dev/null +++ b/apps/slackbot/utilities/default_help.json @@ -0,0 +1,191 @@ +{ + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Welcome to the F3 Nation help menu!", + "style": { + "bold": true + } + }, + { + "type": "text", + "text": "\n\nIf you're looking for a full guide, see the " + }, + { + "type": "link", + "url": "https://docs.google.com/document/d/1e7tmuY3irKDt9oy1URQVcxPwxyet1ZY_bVZhGvhESEw/edit?tab=t.ezcys6v42f0t#heading=h.zi1izp9lf58i", + "text": "start of our manual here" + }, + { + "type": "text", + "text": ". Otherwise, see below on how to find the calendar, sign up to Q, and send out your first preblast! See " + }, + { + "type": "link", + "url": "https://www.youtube.com/watch?v=4A1UroeVA0s&list=PLChC4Hw8Z8x2wG6yk1FwtTKeIySfHT9we", + "text": "this video for a visual demonstration" + }, + { + "type": "text", + "text": " " + }, + { + "type": "emoji", + "name": "movie_camera", + "unicode": "1f3a5" + }, + { + "type": "text", + "text": "\n\n" + }, + { + "type": "text", + "text": "How to find and use the calendar", + "style": { + "bold": true + } + }, + { + "type": "text", + "text": "\n\nYou can get to your region's calendar of workouts by starting to type / and then either use the " + }, + { + "type": "text", + "text": "/f3-calendar", + "style": { + "code": true + } + }, + { + "type": "text", + "text": " or select the \"Open F3 Calendar\" shortcut. There will also be buttons to find the menu, like the one above!\n\nOnce in the calendar view, you can see upcoming events listed. You can filter by date, status, AO, etc. You can interact with the events by clicking the overflow menu button next to it (" + }, + { + "type": "text", + "text": "...", + "style": { + "code": true + } + }, + { + "type": "text", + "text": " on desktop and " + }, + { + "type": "text", + "text": "More", + "style": { + "code": true + } + }, + { + "type": "text", + "text": " on mobile). From there you can Hard Commit (\"HC\"), sign up to Q if it's open, or see the preblast.\n\n" + }, + { + "type": "text", + "text": "How to post a preblast", + "style": { + "bold": true + } + }, + { + "type": "text", + "text": "\n\nTo post a preblast, there are several ways to initialize that:\n" + } + ] + }, + { + "type": "rich_text_list", + "style": "bullet", + "indent": 0, + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Start typing " + }, + { + "type": "text", + "text": "/", + "style": { + "code": true + } + }, + { + "type": "text", + "text": " and either select the " + }, + { + "type": "text", + "text": "/preblast", + "style": { + "code": true + } + }, + { + "type": "text", + "text": " command and hit send, or select the \"Create Preblast\" shortcut" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Click the \"Create Preblast\" button in the reminder message you will get before the event" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Click on the overflow button on the event in the calendar view (" + }, + { + "type": "text", + "text": "...", + "style": { + "code": true + } + }, + { + "type": "text", + "text": " on desktop and " + }, + { + "type": "text", + "text": "More", + "style": { + "code": true + } + }, + { + "type": "text", + "text": " on mobile) and then \"Edit Preblast\"" + } + ] + } + ], + "border": 0 + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\nWhen you use the first option, you will have the option to choose between upcoming events you're signed up to lead, or to post one for an unscheduled event. This should only be used for \"off the books\" events.\n\nAfter filling out the preblast form, you can choose whether you want to post it right now to Slack, or to wait for the automated preblast to be sent (usually around 12 hours before the event). Once it is posted, you can edit by using its Edit button or through the calendar menu.\n\nIf you do not fill out a preblast by the automated time, a generic one will be posted for you." + } + ] + } + ] +} diff --git a/apps/slackbot/utilities/helper_functions.py b/apps/slackbot/utilities/helper_functions.py new file mode 100644 index 00000000..f313df94 --- /dev/null +++ b/apps/slackbot/utilities/helper_functions.py @@ -0,0 +1,918 @@ +import copy +import dataclasses +import json +import os +import re +from dataclasses import dataclass +from datetime import date, datetime +from logging import Logger +from typing import Any, Dict, List, Tuple + +import pytz +import requests +from f3_data_models.models import ( + Location, + Org, + Org_Type, + Org_x_SlackSpace, + Role, + Role_x_User_x_Org, + SlackSpace, + SlackUser, + User, +) +from f3_data_models.utils import DbManager +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.web import SlackResponse, WebClient + +from utilities import constants +from utilities.constants import LOCAL_DEVELOPMENT +from utilities.database.orm import SlackSettings + +REGION_RECORDS: Dict[str, SlackSettings] = {} +SLACK_USERS: Dict[str, SlackUser] = {} + + +def get_location_display_name(location: Location) -> str: + if location.name != "": + return location.name + elif location.description or "" != "": + return location.description[:30] + elif location.address_street or "" != "": + return location.address_street[:30] + else: + return "Unnamed Location" + + +@dataclass +class MapUpdateData: + eventId: int | None = None + locationId: int | None = None + orgId: int | None = None + + +@dataclass +class MapUpdate: + """ + Sample payload: + { + "version": "1.0", + "timestamp": "2025-05-07T19:45:12Z", + "action": "map.updated", // OR map.created / map.deleted + "data": { + "eventId": 1123, // may be null / omitted + "locationId": 987, // may be null / omitted + "orgId": null. // may be null / omitted + } // likely in the future I will send the actual data here too (like new address) + } + """ + + version: str + timestamp: str + action: str + source: str + data: MapUpdateData + + +def trigger_map_revalidation(action: str = None, map_update_data: MapUpdateData = None) -> bool: + if action and map_update_data: + update_info = MapUpdate( + version="1.0", + timestamp=datetime.now(pytz.utc).isoformat(), + action=action, + source="slackbot", + data=map_update_data, + ) + else: + update_info = None + + if not os.environ.get("MAP_REVALIDATION_URL"): + print( + f"Map revalidation URL not set. Would have sent: {dataclasses.asdict(update_info) if update_info else 'No data'}" # noqa + ) + return True + + try: + response = requests.post( + url=os.environ.get("MAP_REVALIDATION_URL"), + headers={ + "Content-Type": "application/json", + "x-api-key": os.environ.get("MAP_REVALIDATION_KEY"), + }, + data=json.dumps(dataclasses.asdict(update_info)) if update_info else None, + ) + response.raise_for_status() + except requests.exceptions.RequestException as e: + print(f"Error triggering map revalidation: {e}") + return False + return True + + +def get_oauth_settings(): + if LOCAL_DEVELOPMENT: + return None + else: + return OAuthSettings( + client_id=os.environ[constants.SLACK_CLIENT_ID], + client_secret=os.environ[constants.SLACK_CLIENT_SECRET], + scopes=os.environ[constants.SLACK_SCOPES].split(","), + installation_store=FileInstallationStore(base_dir="/mnt/oauth_installations"), + state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="/mnt/oauth_states"), + ) + + +def safe_get(data, *keys): + if data is None: + return None + try: + result = data + for k in keys: + # List/tuple index access + if isinstance(k, int) and isinstance(result, (list, tuple)): + if 0 <= k < len(result): + result = result[k] + else: + return None + continue + # SQLAlchemy Row index access (also supports integer indexing) + if isinstance(k, int) and hasattr(result, "_mapping"): + if 0 <= k < len(result): + result = result[k] + else: + return None + continue + # Dict access + elif isinstance(result, dict) or isinstance(result, SlackResponse): + if k in result: + result = result[k] + else: + return None + continue + # SQLAlchemy Row (mapping interface) + elif hasattr(result, "_mapping"): + mapping = result._mapping + if k in mapping: + result = mapping[k] + else: + return None + continue + # Fallback: attribute access + elif hasattr(result, k): + result = getattr(result, k) + else: + return None + return result + except (KeyError, IndexError, TypeError): + return None + + +def get_pax(pax): + p = "" + for x in pax: + p += "<@" + x + "> " + return p + + +def get_channel_names( + array_of_channel_ids, + logger: Logger, + client: WebClient, +): + names = [] + channel_records = client.conversations_list().get("channels") + + for channel_id in array_of_channel_ids: + channel = [u for u in channel_records if u.get("id") == channel_id] + if channel: + channel_name = channel[0].get("name") + else: + channel_info_dict = client.conversations_info(channel=channel_id) + channel_name = safe_get(channel_info_dict, "channel", "name") or None + if channel_name: + names.append(channel_name) + + return names + + +def get_channel_id(name, logger, client): + channel_info_dict = client.conversations_list() + channels = channel_info_dict["channels"] + for channel in channels: + if channel["name"] == name: + return channel["id"] + return None + + +def get_user_names( + array_of_user_ids, + logger, + client: WebClient, + return_urls=False, +): + names = [] + urls = [] + + for user_id in array_of_user_ids: + user: SlackUser = safe_get(SLACK_USERS, user_id) + if user: + names.append(user.user_name) + urls.append(user.avatar_url) + else: + names.append(user_id) + urls.append(None) + + if return_urls: + return names, urls + else: + return names + + +def get_user(slack_user_id: str, region_record: SlackSettings, client: WebClient, logger: Logger) -> SlackUser: + if not SLACK_USERS: + update_local_slack_users() + + user: SlackUser | None = safe_get(SLACK_USERS, slack_user_id) + if not user: + try: + # check to see if this user's email is already in the db + user_info = client.users_info(user=slack_user_id) + slack_user_record = create_user(user_info["user"], region_record.org_id) + + # Update SLACK_USERS with the new id + SLACK_USERS[slack_user_id] = slack_user_record + return slack_user_record + except Exception as e: + raise e + else: + return user + + +def _parse_view_private_metadata(body: dict) -> dict: + """Slack may send private_metadata as a dict, JSON string, or double-encoded JSON string.""" + raw = safe_get(body, "view", "private_metadata") + + if raw in (None, "", {}): + return {} + + if isinstance(raw, dict): + return raw + + value = raw + try: + # Try up to 2 decoding passes (handles values like "\"{\\\"event_instance_id\\\": 441}\""). + for _ in range(2): + if isinstance(value, str): + value = json.loads(value) + else: + break + except (TypeError, json.JSONDecodeError): + return {} + + return value if isinstance(value, dict) else {} + + +def create_user(slack_user_info: dict, home_region_id: int | None = None) -> SlackUser: + email = safe_get(slack_user_info, "profile", "email") + email = email or safe_get(slack_user_info, "id") # this means it's a bot + email = email.lower() + user_name = safe_get(slack_user_info, "profile", "display_name") or safe_get( + slack_user_info, "profile", "real_name" + ) + avatar_url = safe_get(slack_user_info, "profile", "image_192") + user_record = safe_get(DbManager.find_records(User, filters=[User.email == email]), 0) + + # If not, create a new user record + if not user_record: + user_record = DbManager.create_record( + User( + email=email, + f3_name=user_name, + home_region_id=home_region_id, + ) + ) + + slack_user_record = safe_get( + DbManager.find_records(SlackUser, filters=[SlackUser.slack_id == slack_user_info.get("id")]), 0 + ) + if not slack_user_record: + # Create a new slack user record + slack_user_record = DbManager.create_record( + SlackUser( + user_id=safe_get(user_record, "id"), + slack_id=slack_user_info.get("id"), + email=email, + user_name=user_name, + avatar_url=avatar_url, + is_admin=safe_get(slack_user_info, "is_admin") or False, + is_owner=safe_get(slack_user_info, "is_owner") or False, + is_bot=safe_get(slack_user_info, "is_bot") or False, + slack_updated=safe_convert(slack_user_info.get("updated"), int), + slack_team_id=safe_get(slack_user_info, "team_id") or "NOT FOUND", + ) + ) + elif not safe_get(slack_user_record, "user_id"): + DbManager.update_record(SlackUser, slack_user_record.id, {SlackUser.user_id: safe_get(user_record, "id")}) + slack_user_record.user_id = safe_get(user_record, "id") + + # Update SLACK_USERS with the new id + SLACK_USERS[slack_user_info.get("id")] = slack_user_record + return slack_user_record + + +def update_local_slack_users(slack_user: SlackUser = None) -> None: + print("Updating local slack users...") + global SLACK_USERS + + if slack_user: + SLACK_USERS[slack_user.slack_id] = slack_user + return + slack_users: List[SlackUser] = DbManager.find_records(SlackUser, filters=[True]) + + SLACK_USERS.clear() + SLACK_USERS.update({slack_user.slack_id: slack_user for slack_user in slack_users}) + + +def get_region_record(team_id: str, body, context, client, logger) -> SlackSettings: + if not REGION_RECORDS: + update_local_region_records() + + region_record: SlackSettings | None = safe_get(REGION_RECORDS, team_id) + team_domain = safe_get(body, "team", "domain") + + if not region_record: + try: + team_info = client.team_info() + team_name = team_info["team"]["name"] + except Exception: + team_name = team_domain + + org_record = DbManager.find_first_record( + Org, [Org.slack_space.has(SlackSpace.team_id == team_id)], joinedloads=[Org.slack_space] + ) + + settings_starters = { + "team_id": team_id, + "bot_token": context["bot_token"], + "workspace_name": team_name, + } + region_record = SlackSettings(**settings_starters) + if not org_record: + if LOCAL_DEVELOPMENT: + org_record = DbManager.create_record( + Org( + name="My Region", + org_type=Org_Type.region, + is_active=True, + ) + ) + region_record.org_id = org_record.id + else: + settings_starters.update({"org_id": org_record.id}) + region_record = SlackSettings(**settings_starters) + + slack_space_record = SlackSpace( + team_id=team_id, + workspace_name=team_name, + bot_token=context["bot_token"], + settings=region_record.__dict__, + ) + slack_space_record: SlackSpace = DbManager.create_record(slack_space_record) + + if org_record and LOCAL_DEVELOPMENT: + # Connect the org to the slack space + DbManager.create_record( + Org_x_SlackSpace( + org_id=org_record.id, + slack_space_id=slack_space_record.id, + ) + ) + # Make the current user an admin of the org + user_id = get_user( + safe_get(body, "user_id") or safe_get(body, "user", "id"), region_record, client, logger + ).user_id + admin_role_id = DbManager.find_first_record(Role, filters=[Role.name == "admin"]).id + DbManager.create_record( + Role_x_User_x_Org( + user_id=user_id, + org_id=org_record.id, + role_id=admin_role_id, + ) + ) + + REGION_RECORDS[team_id] = region_record + + org_id = org_record.id if org_record else None + populate_users(client, team_id, org_id) + + else: + # Update the bot token if it has changed + if context.get("bot_token") and context.get("bot_token") != region_record.bot_token: + region_record.bot_token = context["bot_token"] + DbManager.update_record( + SlackSpace, + safe_get(DbManager.find_first_record(SlackSpace, filters=[SlackSpace.team_id == team_id]), "id"), + {SlackSpace.settings: region_record.__dict__, SlackSpace.bot_token: context["bot_token"]}, + ) + REGION_RECORDS[team_id] = region_record + + return region_record + + +def populate_users(client: WebClient, team_id: str, org_id: int = None) -> None: + users = client.users_list().get("members") + user_list = [ + User( + f3_name=u["profile"]["display_name"] or u["profile"]["real_name"], + email=u["profile"].get("email") or u["id"], + avatar_url=u["profile"].get("image_192"), + home_region_id=org_id, + ) + for u in users + ] + DbManager.create_or_ignore(User, user_list) + + users_all: List[User] = DbManager.find_records(User, filters=[True]) + users_dict = {u.email: u.id for u in users_all} + + slack_user_list = [ + SlackUser( + slack_id=u["id"], + user_id=users_dict.get(u["profile"].get("email") or u["id"]), + user_name=u["profile"]["display_name"] or u["profile"]["real_name"], + email=u["profile"].get("email") or u["id"], + avatar_url=u["profile"]["image_192"], + slack_team_id=team_id or "NOT FOUND", + is_admin=u.get("is_admin") or False, + is_owner=u.get("is_owner") or False, + is_bot=u.get("is_bot") or False, + slack_updated=safe_convert(safe_get(u, "user", "updated"), int), + ) + for u in users + ] + DbManager.create_or_ignore(SlackUser, slack_user_list) + update_local_slack_users() + + +def get_request_type(body: dict) -> Tuple[str]: + from utilities import routing + + request_type = safe_get(body, "type") + if request_type == "event_callback": + return ("event_callback", safe_get(body, "event", "type")) + elif request_type == "block_actions": + block_action = safe_get(body, "actions", 0, "action_id") + for action in routing.ACTION_PREFIXES: + if block_action[: len(action)] == action: + return ("block_actions", action) + return ("block_actions", block_action) + elif request_type == "view_submission": + return ("view_submission", safe_get(body, "view", "callback_id")) + elif not request_type and "command" in body: + return ("command", safe_get(body, "command")) + elif request_type == "view_closed": + return ("view_closed", safe_get(body, "view", "callback_id")) + elif request_type == "block_suggestion": + return ("block_suggestion", safe_get(body, "action_id")) + elif request_type == "shortcut": + return ("shortcut", safe_get(body, "callback_id")) + else: + return ("unknown", "unknown") + + +def update_local_region_records() -> None: + print("Updating local region records...") + slack_space_records: List[SlackSpace] = DbManager.find_records(SlackSpace, filters=[True]) + region_records = [SlackSettings(**s.settings) for s in slack_space_records] + global REGION_RECORDS + REGION_RECORDS.clear() + REGION_RECORDS.update({region.team_id: region for region in region_records}) + + +def parse_rich_block( + # client: WebClient, + # logger: Logger, + block: Dict[str, Any], + # parse_users: bool = True, + # parse_channels: bool = True, + # region_record: Region = None, +) -> str: + """Extracts the plain text representation from a rich text block. + + Args: + client (WebClient): Slack client + logger (Logger): Logger + block (Dict[str, Any]): Block to parse + parse_users (bool, optional): If True, user mentions will be parsed to their name. Defaults to True. + parse_channels (bool, optional): If True, channel mentions will be parsed to its name. Defaults to True. + + Returns: + str: Extracted plain text + """ + + def process_text_element(text, element): + msg = "" + if element["type"] == "rich_text_quote": + msg += '"' + if text["type"] == "text": + msg += text["text"] + if text["type"] == "emoji": + msg += f":{text['name']}:" + if text["type"] == "link": + msg += text["url"] + if text["type"] == "user": + msg += f"<@{text['user_id']}>" + if text["type"] == "channel": + msg += f"<#{text['channel_id']}>" + if element["type"] == "rich_text_quote": + msg += '"' + return msg + + # user_list = [] + # channel_list = [] + # user_index = 0 + # channel_index = 0 + + msg = "" + + for element in safe_get(block, "elements") or []: + if element["type"] in ["rich_text_section", "rich_text_preformatted", "rich_text_quote"]: + for text in element["elements"]: + msg += process_text_element(text, element) + elif element["type"] == "rich_text_list": + for list_num, item in enumerate(element["elements"]): + line_msg = "" + for text in item["elements"]: + line_msg += process_text_element(text, item) + line_start = f"{list_num + 1}. " if element["style"] == "ordered" else "- " # TODO: handle nested lists + msg += f"{line_start}{line_msg}\n" + return msg + + +def replace_user_channel_ids( + text: str, + region_record: SlackSettings, + client: WebClient, + logger: Logger, +) -> str: + """Replace user and channel ids with their names + + Args: + text (str): text with slack ids + region_record (Region): region record + client (WebClient): slack client + logger (Logger): logger + + Returns: + str: text with slack ids replaced + """ + + USER_PATTERN = r"<@([A-Z0-9]+)>" + CHANNEL_PATTERN = r"<#([A-Z0-9]+)(?:\|[A-Za-z\d]+)?>" + + text = text.replace("{}", "") + + slack_user_ids = re.findall(USER_PATTERN, text or "") + slack_user_names = get_user_names(slack_user_ids, logger, client, return_urls=False) + text = re.sub(USER_PATTERN, "{}", text) + text = text.format(*slack_user_names) + + slack_channel_ids = re.findall(CHANNEL_PATTERN, text or "") + slack_channel_names = get_channel_names(slack_channel_ids, logger, client) + + text = re.sub(CHANNEL_PATTERN, "{}", text) + text = text.format(*slack_channel_names) + + return text + + +def replace_rich_text_user_channel( + block: Dict[str, Any], + region_record: SlackSettings, + client: WebClient, + logger: Logger, +) -> Dict[str, Any]: + """Replace user and channel ids with their names in a rich text block + + Args: + block (Dict[str, Any]): rich text block with slack ids + region_record (SlackSettings): region record + client (WebClient): slack client + logger (Logger): logger + + Returns: + block (Dict[str, Any]): rich text block with slack ids replaced + """ + new_block = copy.deepcopy(block) + + if not new_block or new_block.get("type") != "rich_text": + return new_block + + for element in safe_get(new_block, "elements") or []: + if element["type"] in ["rich_text_section", "rich_text_preformatted", "rich_text_quote"]: + for text in element["elements"]: + if text["type"] == "user": + user_name = get_user_names([text["user_id"]], logger, client, return_urls=False)[0] + text["text"] = f"@{user_name}" + text["type"] = "text" + del text["user_id"] + elif text["type"] == "channel": + channel_name = get_channel_names([text["channel_id"]], logger, client)[0] + text["text"] = f"#{channel_name}" + text["type"] = "text" + del text["channel_id"] + + return new_block + + +def fix_from_llm_tags(block: Dict[str, Any]) -> Dict[str, Any]: + """Removes the 'from_llm' tag that Slack adds in the Android app to rich text blocks + + Args: + block (Dict[str, Any]): rich text block with potential 'from_llm' tags + + Returns: + Dict[str, Any]: rich text block with 'from_llm' tags removed + """ + + if not block or block.get("type") != "rich_text": + return block + + for element in safe_get(block, "elements") or []: + if element["type"] in ["rich_text_section", "rich_text_preformatted", "rich_text_quote"]: + for text in element["elements"]: + if text.get("from_llm") is not None: + del text["from_llm"] + + return block + + +def plain_text_to_rich_block(text: str) -> Dict[str, Any]: + """Converts plain text to a rich text block + + Args: + text (str): plain text + + Returns: + Dict[str, Any]: rich text block + """ + + # split out bolded text using * + split_text = re.split(r"(\*.*?\*)", text) + text_elements = [ + ( + {"type": "text", "text": s.replace("*", ""), "style": {"bold": True}} + if s.startswith("*") + else {"type": "text", "text": s} + ) + for s in split_text + ] + + # now convert emojis + final_text_elements = [] + for element in text_elements: + if element["type"] == "text" and not element.get("style"): + split_emoji_text = re.split(r"(:\S*?:)", element["text"]) + emoji_elements = [ + ( + {"type": "emoji", "name": s.replace(":", "")} + if s.startswith(":") and s.endswith(":") + else {"type": "text", "text": s} + ) + for s in split_emoji_text + ] + final_text_elements.extend(emoji_elements) + else: + final_text_elements.append(element) + + final_text_elements = [e for e in final_text_elements if e.get("text") != ""] + + return { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": final_text_elements, + } + ], + } + + +def remove_keys_from_dict(d, keys_to_remove): + if isinstance(d, dict): + return { + key: remove_keys_from_dict(value, keys_to_remove) for key, value in d.items() if key not in keys_to_remove + } + elif isinstance(d, list): + return [remove_keys_from_dict(item, keys_to_remove) for item in d] + else: + return d + + +def safe_convert(value: str | None, conversion, args: list = None, default=None): + """ + Safely apply conversion to value. + Returns default (None unless specified) when: + - value is None or empty string + - conversion raises TypeError or ValueError (e.g., int(""), json.loads("")) + """ + if value is None or value == "": + return default + args = args or [] + try: + return conversion(value, *args) + except (TypeError, ValueError): + return default + + +def time_int_to_str(time: int) -> str: + return f"{time // 100:02d}:{time % 100:02d}" + + +def time_str_to_int(time: str) -> int: + return int(time.replace(":", "")) + + +def current_date_cst() -> date: + """Returns the current date in US/Central timezone.""" + return datetime.now(pytz.timezone("US/Central")).date() + + +def upload_files_to_storage( + files: List[Dict[str, str]], + client: WebClient, + logger: Logger, + enforce_square: bool = False, + max_height: int = None, + bucket_name: str = None, + file_name: str = None, + enforce_png: bool = False, +) -> Tuple[List[str], List[Dict[str, Any]], List[str], List[str]]: + file_list = [] + file_send_list = [] + file_ids = [file["id"] for file in files] + low_res_file_list = [] + from PIL import Image + + bucket_name = bucket_name or "backblast-images" + + for file in files or []: + try: + r_full = requests.get(file["url_private_download"], headers={"Authorization": f"Bearer {client.token}"}) + r_full.raise_for_status() + + file_id = file_name or file["id"] + current_file_name = f"{file_id}.{file['filetype']}" + file_path = f"/mnt/{bucket_name}/{current_file_name}" + file_mimetype = file["mimetype"] + + # Determine the highest thumbnail size possible + thumb_sizes = [64, 80, 160, 360, 480, 720, 800, 960, 1024] + # Find the largest thumbnail that actually exists in the file object + available_thumbs = [size for size in thumb_sizes if f"thumb_{size}" in file] + thumb_size = max(available_thumbs) if available_thumbs else None + + with open(file_path, "wb") as f: + f.write(r_full.content) + + if thumb_size is not None: + r_low_res = requests.get( + file[f"thumb_{thumb_size}"], + headers={"Authorization": f"Bearer {client.token}"}, + params={"width": constants.LOW_REZ_IMAGE_SIZE, "height": constants.LOW_REZ_IMAGE_SIZE}, + ) + file_name_low_res = f"{file_id}_low_res.png" + file_path_low_res = f"/mnt/{bucket_name}/{file_name_low_res}" + + with open(file_path_low_res, "wb") as f: + f.write(r_low_res.content) + + low_res_file_list.append( + f"https://storage.googleapis.com/{constants.FILE_BUCKET_PREFIX}/{bucket_name}/{file_name_low_res}" + ) + + change_to_png = enforce_png and file["filetype"] != "png" + if enforce_square or max_height or change_to_png: + img = None + if file_mimetype.startswith("image/"): + try: + img = Image.open(file_path) + except Exception as e: + logger.error(f"Error opening image: {e}") + + # If we have an image, apply enforce_square and max_height + if img is not None: + # Enforce square if requested + if enforce_square: + max_side = max(img.width, img.height) + new_img = Image.new("RGB", (max_side, max_side), (0, 0, 0)) + paste_x = (max_side - img.width) // 2 + paste_y = (max_side - img.height) // 2 + new_img.paste(img, (paste_x, paste_y)) + img = new_img + # Downscale to max_height if provided (after squaring) + if max_height is not None and img.height > max_height: + img = img.resize((max_height, max_height), Image.LANCZOS) + # Save the possibly modified image + img_format = "PNG" if file["filetype"] == "heic" or change_to_png else img.format + if img_format == "PNG" and not current_file_name.endswith(".png"): + old_path = file_path + current_file_name = f"{file_id}.png" + file_path = f"/mnt/{bucket_name}/{current_file_name}" + file_mimetype = "image/png" + else: + old_path = None + img.save(file_path, format=img_format, quality=95, optimize=True) + if old_path and old_path != file_path: + os.remove(old_path) + + # TODO: if LOCAL_DEVELOPMENT, upload to google storage + + file_list.append( + f"https://storage.googleapis.com/{constants.FILE_BUCKET_PREFIX}/{bucket_name}/{current_file_name}" + ) + file_send_list.append( + { + "filepath": file_path, + "meta": { + "filename": current_file_name, + "maintype": file_mimetype.split("/")[0], + "subtype": file_mimetype.split("/")[1], + }, + } + ) + except Exception as e: + logger.error(f"Error uploading file: {e}") + + return file_list, file_send_list, file_ids, low_res_file_list + + +def highest_resolution_thumb(file: Dict[str, Any]) -> str: + thumb_sizes = [64, 80, 160, 360, 480, 720, 800, 960, 1024] + available_thumbs = [size for size in thumb_sizes if f"thumb_{size}" in file] + if not available_thumbs: + return None + thumb_size = max(available_thumbs) + return file[f"thumb_{thumb_size}"] + + +def reupload_file_as_bot( + file: Dict[str, Any], + client: WebClient, + logger: Logger, + region_record: SlackSettings = None, + filename: str = None, +) -> str | None: + """Download a user-uploaded Slack file and re-upload it as the bot to the bot log channel. + + Uploading to the public bot log channel ensures the file is accessible workspace-wide, + allowing it to be referenced in slack_file image blocks in channel messages. + + Returns the bot-owned file ID, or None on failure. + """ + from utilities.bot_logger import _ensure_bot_in_channel, _find_or_create_log_channel # noqa: PLC0415 + + try: + r = requests.get(file["url_private_download"], headers={"Authorization": f"Bearer {client.token}"}) + r.raise_for_status() + fname = filename or f"{file['id']}.{file.get('filetype', 'bin')}" + + # Resolve the bot log channel so the uploaded file is publicly accessible workspace-wide + channel_id = region_record.bot_log_channel if region_record else None + if not channel_id: + channel_id = _find_or_create_log_channel(client, logger) + if channel_id: + _ensure_bot_in_channel(client, channel_id, logger) + + response = client.files_upload_v2( + filename=fname, + file=r.content, + channel=channel_id, + ) + print(f"Re-uploaded file {file['id']} as bot with new file ID {safe_get(response, 'file', 'id')}") + print(f"Response from Slack API: {response}") + return safe_get(response, "files", 0, "id") + except Exception as e: + logger.error(f"Error re-uploading file {safe_get(file, 'id')} as bot: {e}") + return None + + +# Helper function to sort by name, ignoring any prefixes we might want to ignore +# Example: The Name, should just be sorted as Name +def sort_by_name(extractor): + prefixes = ("the ",) + prefixes = tuple(p.casefold() for p in prefixes) + + def key(obj): + value = (extractor(obj) or "").strip() + folded = value.casefold() + + for p in prefixes: + if folded.startswith(p): + folded = folded[len(p) :] + break + + return folded + + return key diff --git a/apps/slackbot/utilities/options.py b/apps/slackbot/utilities/options.py new file mode 100644 index 00000000..c653e598 --- /dev/null +++ b/apps/slackbot/utilities/options.py @@ -0,0 +1,119 @@ +from logging import Logger +from typing import Optional + +from f3_data_models.models import Org, Org_Type, User +from f3_data_models.utils import DbManager +from slack_sdk import WebClient +from sqlalchemy import and_ + +from features import connect as connect_form +from features import paxminer_mapping +from features import user as user_form +from utilities.database.orm import SlackSettings +from utilities.helper_functions import safe_get +from utilities.slack import actions + +USER_SEARCH_LIMIT = 50 + + +def _parse_search_value(value: str) -> tuple[str, Optional[str]]: + """Split a search string like 'rabbit (capi' into ('rabbit', 'capi'). + + Returns (name_query, region_query) where region_query is None if no '(' present. + The closing ')' is stripped if present. + """ + if "(" in value: + name_part, region_part = value.split("(", 1) + return name_part.strip(), region_part.rstrip(")").strip() + return value.strip(), None + + +def _relevance_score(f3_name: str, search_term: str) -> tuple[int, str]: + """Return (score, f3_name) for sorting: exact=0, starts-with=1, contains=2.""" + name_lower = f3_name.lower() + term_lower = search_term.lower() + if name_lower == term_lower: + return (0, f3_name) + if name_lower.startswith(term_lower): + return (1, f3_name) + return (2, f3_name) + + +def _search_users(value: str, limit: int = USER_SEARCH_LIMIT) -> list[dict]: + """Search users by f3_name with optional region filter via '(' syntax. + + Supports search terms like 'rabbit (capi' to filter by both name and home region. + Results are sorted by relevance (exact > starts-with > contains), then alphabetically. + """ + name_query, region_query = _parse_search_value(value) + if not name_query: + return [] + + user_records = DbManager.find_records( + cls=User, + filters=[User.f3_name.ilike(f"%{name_query}%")], + joinedloads=[User.home_region_org], + ) + + if region_query: + region_lower = region_query.lower() + user_records = [u for u in user_records if u.home_region_org and region_lower in u.home_region_org.name.lower()] + + user_records.sort(key=lambda u: _relevance_score(u.f3_name or "", name_query)) + + options = [] + for user in user_records[:limit]: + display_name = user.f3_name or "Unknown" + if user.home_region_org: + display_name += f" ({user.home_region_org.name})" + options.append( + { + "text": {"type": "plain_text", "text": display_name}, + "value": str(user.id), + } + ) + return options + + +def handle_request( + body: dict, + client: WebClient, + logger: Logger, + context: dict, + region_record: SlackSettings, +): + action_id = safe_get(body, "action_id") + value = safe_get(body, "value") + + if action_id == actions.USER_OPTION_LOAD: + return _search_users(value) + elif action_id == user_form.USER_FORM_BROUGHT_BY: + return _search_users(value) + elif action_id in [ + user_form.USER_FORM_HOME_REGION, + connect_form.SELECT_REGION, + paxminer_mapping.PAXMINER_REGION, + actions.DOWNRANGE_REGION_SELECT, + ]: + # Handle the home region selection + org_records = DbManager.find_records( + cls=Org, + filters=[and_(Org.name.ilike(f"%{value}%"), Org.org_type == Org_Type.region)], + # TODO: add area / sector as description + ) + options = [] + for org in org_records[:USER_SEARCH_LIMIT]: + display_name = org.name + options.append( + { + "text": {"type": "plain_text", "text": display_name}, + "value": str(org.id), + } + ) + return options + elif action_id == actions.EMERGENCY_DR_USER_SELECT: + # Handle downrange emergency user search + options = _search_users(value) + # TODO: filter for users who have opted into DR sharing + # options = [o for o in options if ...check meta for emergency.USER_EMERGENCY_INFO_DR_SHARING...] + return options diff --git a/apps/slackbot/utilities/routing.py b/apps/slackbot/utilities/routing.py new file mode 100644 index 00000000..33c7ca39 --- /dev/null +++ b/apps/slackbot/utilities/routing.py @@ -0,0 +1,285 @@ +from features import ( + achievements, + backblast, + canvas, + config, + connect, + custom_fields, + db_admin, + downrange, + emergency, + help, + paxminer_mapping, + positions, + region, + reporting, + special_events, + strava, + user, + weaselbot, + welcome, +) +from features.calendar import ( + ao, + event_instance, + event_preblast, + event_tag, + event_type, + home, + location, + nearby_events, + series, +) +from features.calendar import config as calendar_config +from scripts.backblast_reminders import handle_backblast_reminder_dismiss +from scripts.home_region_nudge import handle_home_region_dismiss, handle_home_region_opt_out, handle_home_region_switch +from scripts.monthly_reporting import run_reporting_single_org +from scripts.q_lineups import handle_lineup_signup +from utilities import builders, options +from utilities.slack import actions + +# Required arguments for handler functions: +# body: dict +# client: WebClient +# logger: Logger +# context: dict + +# The mappers define the function to be called for each event +# The boolean value indicates whether a loading modal should be triggered before running the function + +COMMAND_MAPPER = { + "/backblast": (backblast.backblast_middleware, True), + "/preblast": (event_preblast.preblast_middleware, True), + "/f3-nation-settings": (config.build_config_form, True), + "/tag-achievement": (achievements.build_tag_achievement_form, True), + "/f3-calendar": (home.build_home_form, True), + "/help": (help.build_help_menu, False), +} + +VIEW_MAPPER = { + actions.BACKBLAST_CALLBACK_ID: (backblast.handle_backblast_post, False), + actions.BACKBLAST_EDIT_CALLBACK_ID: (backblast.handle_backblast_post, False), + actions.WELCOME_MESSAGE_CONFIG_CALLBACK_ID: (welcome.handle_welcome_message_config_post, False), + actions.CONFIG_GENERAL_CALLBACK_ID: (config.handle_config_general_post, False), + actions.CONFIG_EMAIL_CALLBACK_ID: (config.handle_config_email_post, False), + actions.STRAVA_MODIFY_CALLBACK_ID: (strava.handle_strava_modify, False), + actions.CUSTOM_FIELD_ADD_CALLBACK_ID: (custom_fields.handle_custom_field_add, False), + actions.CUSTOM_FIELD_MENU_CALLBACK_ID: (custom_fields.handle_custom_field_menu, False), + actions.ACHIEVEMENT_CALLBACK_ID: (achievements.handle_tag_achievement, False), + actions.ACHIEVEMENT_TAG_CALLBACK_ID: (achievements.handle_tag_achievement, False), + actions.ACHIEVEMENT_CONFIG_CALLBACK_ID: (achievements.handle_config_form, False), + actions.ACHIEVEMENT_NEW_CALLBACK_ID: (achievements.handle_new_achievement_form, False), + actions.WEASELBOT_CONFIG_CALLBACK_ID: (weaselbot.handle_config_form, False), + location.ADD_LOCATION_CALLBACK_ID: (location.handle_location_add, False), + actions.ADD_AO_CALLBACK_ID: (ao.handle_ao_add, False), + actions.ADD_SERIES_CALLBACK_ID: (series.handle_series_add, False), + actions.EVENT_PREBLAST_CALLBACK_ID: (event_preblast.handle_event_preblast_edit, False), + event_type.CALENDAR_ADD_EVENT_TYPE_CALLBACK_ID: (event_type.handle_event_type_add, False), + actions.EVENT_PREBLAST_POST_CALLBACK_ID: (event_preblast.handle_event_preblast_edit, False), + actions.REGION_CALLBACK_ID: (region.handle_region_edit, False), + actions.SPECIAL_EVENTS_CALLBACK_ID: (special_events.handle_special_settings_edit, False), + actions.CONFIG_SLT_CALLBACK_ID: (positions.handle_config_slt_post, False), + actions.NEW_POSITION_CALLBACK_ID: (positions.handle_new_position_post, False), + actions.EDIT_POSITION_CALLBACK_ID: (positions.handle_edit_position_post, False), + connect.CONNECT_EXISTING_REGION_CALLBACK_ID: (connect.handle_existing_region_selection, False), + connect.CREATE_NEW_REGION_CALLBACK_ID: (connect.handle_new_region_creation, False), + actions.CALENDAR_CONFIG_GENERAL_CALLBACK_ID: (calendar_config.handle_calendar_config_general, False), + user.USER_FORM_ID: (user.handle_user_form, False), + event_instance.ADD_EVENT_INSTANCE_CALLBACK_ID: (event_instance.handle_event_instance_add, False), + event_tag.CALENDAR_ADD_EVENT_TAG_CALLBACK_ID: (event_tag.handle_event_tag_add, False), + actions.HOME_ASSIGN_Q_CALLBACK_ID: (home.handle_assign_q_form, False), + actions.DB_ADMIN_CALLBACK_ID: (db_admin.handle_send_admin_announcement, False), + reporting.REPORTING_CALLBACK_ID: (reporting.handle_reporting_edit, False), + actions.DB_ADMIN_LONG_RUN_CALLBACK_ID: (db_admin.handle_long_run_task, False), + paxminer_mapping.PAXMINER_MAPPING_ID: (paxminer_mapping.handle_paxminer_mapping_post, False), + event_instance.EVENT_CLOSE_CALLBACK_ID: (event_instance.handle_event_instance_close, False), + actions.EVENT_CLOSE_HOME_CALLBACK_ID: (home.handle_event_instance_close, False), + actions.DOWNRANGE_CALLBACK_ID: (downrange.handle_downrange_settings, False), +} + +ACTION_MAPPER = { + actions.BACKBLAST_EDIT_BUTTON: (backblast.handle_backblast_edit_button, True), + actions.BACKBLAST_EDIT_BUTTON_LEGACY: (backblast.handle_legacy_edit_button, True), + actions.BACKBLAST_NEW_BUTTON: (backblast.backblast_middleware, True), + actions.BACKBLAST_STRAVA_BUTTON: (strava.build_strava_form, True), + actions.STRAVA_ACTIVITY_BUTTON: (strava.build_strava_modify_form, False), + actions.STRAVA_CONNECT_BUTTON: (builders.ignore_event, False), + actions.CONFIG_CUSTOM_FIELDS: (custom_fields.build_custom_field_menu, False), + actions.CUSTOM_FIELD_ADD: (custom_fields.build_custom_field_add_edit, False), + actions.CUSTOM_FIELD_EDIT: (custom_fields.build_custom_field_add_edit, False), + actions.CUSTOM_FIELD_DELETE: (custom_fields.delete_custom_field, False), + actions.PREBLAST_NEW_BUTTON: (event_preblast.preblast_middleware, True), + actions.CONFIG_WEASELBOT: (weaselbot.build_config_form, False), + actions.CONFIG_ACHIEVEMENTS: (achievements.build_config_form, False), + actions.ACHIEVEMENT_CONFIG_NEW_BTN: (achievements.build_new_achievement_form, False), + actions.ACHIEVEMENT_CONFIG_MANAGE_BTN: (achievements.build_manage_achievements_form, False), + actions.ACHIEVEMENT_MANAGE_OVERFLOW: (achievements.handle_manage_overflow, False), + actions.CONFIG_WELCOME_MESSAGE: (welcome.build_welcome_message_form, False), + actions.CONFIG_EMAIL: (config.build_config_email_form, False), + actions.CONFIG_GENERAL: (config.build_config_general_form, False), + actions.CONFIG_WELCOME_MESSAGE: (welcome.build_welcome_config_form, False), + actions.CONFIG_CALENDAR: (calendar_config.build_calendar_config_form, False), + actions.CALENDAR_ADD_SERIES_AO: (series.build_series_add_form, False), + actions.SERIES_EDIT_DELETE: (series.handle_series_edit_delete, False), + location.LOCATION_EDIT_DELETE: (location.handle_location_edit_delete, False), + actions.AO_EDIT_DELETE: (ao.handle_ao_edit_delete, False), + actions.CALENDAR_ADD_EVENT_AO: (series.build_series_add_form, False), + location.CALENDAR_MANAGE_LOCATIONS: (location.manage_locations, False), + actions.CALENDAR_MANAGE_AOS: (ao.manage_aos, False), + actions.CALENDAR_MANAGE_SERIES: (series.manage_series, False), + actions.CALENDAR_MANAGE_EVENTS: (series.manage_series, False), + event_type.CALENDAR_MANAGE_EVENT_TYPES: (event_type.manage_event_types, False), + location.CALENDAR_ADD_AO_NEW_LOCATION: (location.build_location_add_form, False), + actions.CALENDAR_HOME_EVENT: (home.handle_home_event, False), + actions.CALENDAR_HOME_AO_FILTER: (home.build_home_form, False), + actions.CALENDAR_HOME_Q_FILTER: (home.build_home_form, False), + actions.CALENDAR_HOME_DATE_FILTER: (home.build_home_form, False), + actions.CALENDAR_HOME_EVENT_TYPE_FILTER: (home.build_home_form, False), + actions.EVENT_PREBLAST_HC: (event_preblast.handle_event_preblast_action, False), + actions.EVENT_PREBLAST_UN_HC: (event_preblast.handle_event_preblast_action, False), + actions.EVENT_PREBLAST_TAKE_Q: (event_preblast.handle_event_preblast_action, False), + actions.EVENT_PREBLAST_REMOVE_Q: (event_preblast.handle_event_preblast_action, False), + actions.EVENT_PREBLAST_HC_UN_HC: (event_preblast.handle_event_preblast_action, False), + actions.EVENT_PREBLAST_EDIT: (event_preblast.handle_event_preblast_action, False), + actions.EVENT_PREBLAST_SELECT: (event_preblast.handle_event_preblast_select, False), + actions.EVENT_PREBLAST_NEW_BUTTON: (home.handle_event_preblast_select_button, False), + actions.OPEN_CALENDAR_BUTTON: (home.handle_event_preblast_select_button, True), + actions.MSG_EVENT_PREBLAST_BUTTON: (event_preblast.handle_event_preblast_action, False), + actions.MSG_EVENT_BACKBLAST_BUTTON: (backblast.backblast_middleware, True), + actions.MSG_EVENT_BACKBLAST_ALREADY_BUTTON: (handle_backblast_reminder_dismiss, False), + actions.BACKBLAST_FILL_SELECT: (backblast.build_backblast_form, True), + actions.BACKBLAST_NEW_BLANK_BUTTON: (backblast.build_backblast_form, True), + actions.REGION_INFO_BUTTON: (region.build_region_form, False), + actions.CONFIG_SPECIAL_EVENTS: (special_events.build_special_settings_form, False), + actions.DB_ADMIN_UPGRADE: (db_admin.handle_db_admin_upgrade, False), + actions.DB_ADMIN_RESET: (db_admin.handle_db_admin_reset, False), + actions.SECRET_MENU_CALENDAR_IMAGES: (db_admin.handle_calendar_image_refresh, False), + actions.CONFIG_SLT: (positions.build_config_slt_form, True), + actions.SLT_LEVEL_SELECT: (positions.build_config_slt_form, False), + actions.CONFIG_NEW_POSITION: (positions.build_new_position_form, False), + actions.CONFIG_EDIT_POSITIONS: (positions.build_position_list_form, False), + actions.POSITION_EDIT_DELETE: (positions.handle_position_edit_delete, False), + actions.CONFIG_CONNECT: (connect.build_connect_options_form, False), + connect.CONNECT_EXISTING_REGION: (connect.build_existing_region_form, False), + connect.CREATE_NEW_REGION: (connect.build_new_region_form, False), + actions.SECRET_MENU_UPDATE_CANVAS: (canvas.update_canvas, False), + actions.SECRET_MENU_MAKE_ADMIN: (db_admin.handle_make_admin, False), + actions.SECRET_MENU_MAKE_ORG: (db_admin.handle_make_org, False), + actions.CALENDAR_CONFIG_GENERAL: (calendar_config.build_calendar_general_config_form, False), + actions.SECRET_MENU_AO_LINEUPS: (db_admin.handle_ao_lineups, False), + actions.OPEN_CALENDAR_MSG_BUTTON: (home.build_home_form, True), + actions.SECRET_MENU_PREBLAST_REMINDERS: (db_admin.handle_preblast_reminders, False), + actions.SECRET_MENU_BACKBLAST_REMINDERS: (db_admin.handle_backblast_reminders, False), + actions.LINEUP_SIGNUP_BUTTON: (handle_lineup_signup, False), + actions.SECRET_MENU_TRIGGER_MAP_REVALIDATION: (db_admin.handle_trigger_map_revalidation, False), + actions.CONFIG_USER_SETTINGS: (user.build_user_form, True), + event_instance.CALENDAR_MANAGE_EVENT_INSTANCE: (event_instance.manage_event_instances, False), + actions.EVENT_INSTANCE_EDIT_DELETE: (event_instance.handle_event_instance_edit_delete, False), + event_instance.CALENDAR_ADD_EVENT_INSTANCE_AO: (event_instance.build_event_instance_add_form, False), + event_instance.CALENDAR_MANAGE_EVENT_INSTANCE_AO: (event_instance.build_event_instance_list_form, False), + event_instance.CALENDAR_MANAGE_EVENT_INSTANCE_DATE: (event_instance.build_event_instance_list_form, False), + event_type.EVENT_TYPE_EDIT_DELETE: (event_type.handle_event_type_edit_delete, False), + actions.OPEN_CALENDAR_IMAGE_BUTTON: (home.build_calendar_image_form, False), + event_tag.EVENT_TAG_EDIT_DELETE: (event_tag.handle_event_tag_edit_delete, False), + event_tag.CALENDAR_MANAGE_EVENT_TAGS: (event_tag.manage_event_tags, False), + actions.SECRET_MENU_REFRESH_SLACK_USERS: (db_admin.handle_slack_user_refresh, False), + actions.SECRET_MENU_UPDATE_BOT_TOKEN: (db_admin.handle_update_bot_token, False), + connect.DENY_CONNECTION: (connect.handle_deny_connection, False), + connect.APPROVE_CONNECTION: (connect.handle_approve_connection, False), + user.IGNORE_EVENT: (builders.ignore_event, False), + actions.CONFIG_REPORTING: (reporting.build_reporting_form, False), + reporting.RUN_MONTHLY_REPORTS_NOW: (run_reporting_single_org, False), + actions.CONFIG_HELP_MENU: (help.build_help_menu, False), + actions.CALENDAR_MANAGE_SERIES_AO: (series.build_series_list_form, False), + actions.SETTINGS_BUTTON: (config.build_config_form, True), + actions.SECRET_MENU_LONG_RUN: (db_admin.build_long_run_task_form, False), + actions.PAXMINER_MAPPING: (paxminer_mapping.build_paxminer_mapping_form, False), + paxminer_mapping.PAXMINER_ORIGINATING_CHANNEL: (paxminer_mapping.build_paxminer_mapping_form, False), + paxminer_mapping.PAXMINER_REGION: (paxminer_mapping.build_paxminer_mapping_form, False), + actions.PREBLAST_FILL_BACKBLAST_BUTTON: (backblast.backblast_middleware, True), + actions.NEW_PREBLAST_BUTTON: (event_preblast.preblast_middleware, True), + actions.BACKBLAST_NOQ_SELECT: (backblast.build_backblast_form, True), + actions.BACKBLAST_FILL_BUTTON: (backblast.build_backblast_form, True), + actions.EVENT_PREBLAST_FILL_BUTTON: (event_preblast.handle_event_preblast_select, False), + actions.EVENT_PREBLAST_NOQ_SELECT: (event_preblast.handle_event_preblast_select, False), + actions.PREBLAST_OVERFLOW_ACTION: (event_preblast.route_preblast_overflow_action, False), + actions.SECRET_MENU_SEND_AUTO_PREBLASTS: (db_admin.handle_auto_preblast_send, False), + actions.SECRET_MENU_REFRESH_CACHE: (db_admin.handle_refresh_cache, False), + actions.CONFIG_EMERGENCY_INFO: (emergency.build_emergency_search_form, True), + actions.EMERGENCY_LOCAL_USER_SELECT: (emergency.handle_local_user_select, False), + actions.EMERGENCY_DR_USER_SELECT: (emergency.handle_dr_user_select, False), + actions.CONFIG_DOWNRANGE: (downrange.build_downrange_menu, True), + actions.DOWNRANGE_REGION_SELECT: (downrange.handle_region_select, False), + actions.DOWNRANGE_INVITE_REQUEST_BUTTON: (downrange.handle_invite_request, False), + actions.DOWNRANGE_INVITE_APPROVE_BUTTON: (downrange.handle_invite_approve, False), + actions.DOWNRANGE_INVITE_DENY_BUTTON: (downrange.handle_invite_deny, False), + actions.DOWNRANGE_INVITE_LINK_BROKEN_BUTTON: (downrange.handle_invite_link_broken, False), + actions.DOWNRANGE_INVITE_MARK_DONE_BUTTON: (downrange.handle_invite_mark_done, False), + actions.HOME_REGION_NUDGE_SWITCH_BUTTON: (handle_home_region_switch, False), + actions.HOME_REGION_NUDGE_DISMISS_BUTTON: (handle_home_region_dismiss, False), + actions.HOME_REGION_NUDGE_OPT_OUT_BUTTON: (handle_home_region_opt_out, False), + actions.NEARBY_EVENTS_OPEN: (nearby_events.build_nearby_events_modal, False), + actions.NEARBY_EVENTS_DISTANCE: (nearby_events.build_nearby_events_modal, False), + actions.NEARBY_EVENTS_SORT: (nearby_events.build_nearby_events_modal, False), + actions.NEARBY_EVENTS_HC: (nearby_events.handle_nearby_events_hc_action, False), + actions.NEARBY_EVENTS_UN_HC: (nearby_events.handle_nearby_events_hc_action, False), + actions.MISSING_BACKBLASTS_BUTTON: (backblast.build_missing_backblasts_form, True), + actions.MISSING_BACKBLASTS_AO_FILTER: (backblast.build_missing_backblasts_form, False), + actions.MISSING_BACKBLASTS_EVENT: (backblast.handle_missing_backblasts_overflow, False), +} + +ACTION_PREFIXES = [ + actions.STRAVA_ACTIVITY_BUTTON, + actions.SERIES_EDIT_DELETE, + location.LOCATION_EDIT_DELETE, + actions.AO_EDIT_DELETE, + actions.CALENDAR_HOME_EVENT, + actions.BACKBLAST_FILL_SELECT, + actions.LINEUP_SIGNUP_BUTTON, + actions.EVENT_INSTANCE_EDIT_DELETE, + event_type.EVENT_TYPE_EDIT_DELETE, + event_tag.EVENT_TAG_EDIT_DELETE, + actions.BACKBLAST_FILL_BUTTON, + actions.EVENT_PREBLAST_FILL_BUTTON, + actions.POSITION_EDIT_DELETE, + actions.MISSING_BACKBLASTS_EVENT, + actions.ACHIEVEMENT_MANAGE_OVERFLOW, +] + +VIEW_CLOSED_MAPPER = { + actions.CUSTOM_FIELD_ADD_FORM: (builders.ignore_event, False), + actions.STRAVA_MODIFY_CALLBACK_ID: (strava.handle_strava_modify, False), +} + +EVENT_MAPPER = { + "team_join": (welcome.handle_team_join, False), + "app_mention": (help.handle_app_mention, False), +} + +OPTIONS_MAPPER = { + actions.USER_OPTION_LOAD: (options.handle_request, False), + user.USER_FORM_HOME_REGION: (options.handle_request, False), + user.USER_FORM_BROUGHT_BY: (options.handle_request, False), + paxminer_mapping.PAXMINER_REGION: (options.handle_request, False), + connect.SELECT_REGION: (options.handle_request, False), + actions.EMERGENCY_DR_USER_SELECT: (options.handle_request, False), + actions.DOWNRANGE_REGION_SELECT: (options.handle_request, False), +} + +SHORTCUT_MAPPER = { + actions.BACKBLAST_SHORTCUT: (backblast.backblast_middleware, True), + actions.PREBLAST_SHORTCUT: (event_preblast.preblast_middleware, True), + actions.CALENDAR_SHORTCUT: (home.build_home_form, True), + actions.SETTINGS_SHORTCUT: (config.build_config_form, True), + actions.TAG_ACHIEVEMENT_SHORTCUT: (achievements.build_tag_achievement_form, True), +} + +MAIN_MAPPER = { + "command": COMMAND_MAPPER, + "block_actions": ACTION_MAPPER, + "view_submission": VIEW_MAPPER, + "view_closed": VIEW_CLOSED_MAPPER, + "event_callback": EVENT_MAPPER, + "block_suggestion": OPTIONS_MAPPER, + "shortcut": SHORTCUT_MAPPER, +} diff --git a/apps/slackbot/utilities/sendmail.py b/apps/slackbot/utilities/sendmail.py new file mode 100644 index 00000000..7d70261e --- /dev/null +++ b/apps/slackbot/utilities/sendmail.py @@ -0,0 +1,95 @@ +import logging +import os +import smtplib +from email.message import EmailMessage +from pathlib import Path +from typing import Any, Dict, List + +logger = logging.getLogger(__name__) + + +def send_via_sendgrid( + to_email: str, + subject: str, + html_content: str, + from_email: str = "noreply@f3nation.com", +) -> bool: + """Send an email using SendGrid API. + + Args: + to_email: Recipient email address + subject: Email subject line + html_content: HTML body content + from_email: Sender email address (default: noreply@f3nation.com) + + Returns: + True if email was sent successfully, False otherwise + """ + api_key = os.getenv("SENDGRID_API_KEY") + if not api_key: + logger.warning("SENDGRID_API_KEY not configured, skipping email send") + return False + + if not to_email: + logger.warning("No recipient email provided, skipping email send") + return False + + try: + from sendgrid import SendGridAPIClient + from sendgrid.helpers.mail import Mail + + message = Mail( + from_email=from_email, + to_emails=to_email, + subject=subject, + html_content=html_content, + ) + sg = SendGridAPIClient(api_key) + response = sg.send(message) + logger.info(f"SendGrid email sent to {to_email}, status: {response.status_code}") + return response.status_code in (200, 201, 202) + except Exception as e: + logger.error(f"Failed to send email via SendGrid: {e}") + return False + + +def send( + subject: str, + body: str, + email_server: str, + email_server_port: int, + email_user: str, + email_password: str, + email_to: str, + attachments: List[Dict[str, Any]], +): + """Construct and sends an email. + + Args: + subject (str): email subject + body (str): email body + email_server (str): email server + email_server_port (str): email server port + email_user (str): email user address + email_password (str): email password + email_to (str): email recipient address + attachments (List[Dict[str, Any]]): list of attachments, each attachment is a dict with keys "filepath" and + "meta", where meta includes filename, maintype, and subtype + """ + msg = EmailMessage() + msg.set_content(body) + + msg["Subject"] = subject + msg["From"] = email_user + msg["To"] = email_to + + for file in attachments: + with Path(file["filepath"]).open("rb") as f: + msg.add_attachment(f.read(), **file["meta"]) + + if email_server and email_server_port and email_user and email_password and email_to: + server = smtplib.SMTP(email_server, email_server_port) + server.starttls() + server.login(email_user, email_password) + server.send_message(msg) + server.close() diff --git a/apps/slackbot/utilities/slack/__init__.py b/apps/slackbot/utilities/slack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/slackbot/utilities/slack/actions.py b/apps/slackbot/utilities/slack/actions.py new file mode 100644 index 00000000..be1807e2 --- /dev/null +++ b/apps/slackbot/utilities/slack/actions.py @@ -0,0 +1,386 @@ +LOADING = "loading" +OPEN_CALENDAR_MSG_BUTTON = "open-calendar-msg-button" +BACKBLAST_CALLBACK_ID = "backblast-id" +BACKBLAST_TITLE = "title" +BACKBLAST_AO = "The_AO" +BACKBLAST_DATE = "date" +BACKBLAST_Q = "the_q" +BACKBLAST_COQ = "the_coq" +BACKBLAST_PAX = "the_pax" +BACKBLAST_NONSLACK_PAX = "non_slack_pax" +BACKBLAST_COUNT = "count" +BACKBLAST_FNGS = "fngs" +BACKBLAST_MOLESKIN = "moleskin" +BACKBLAST_DESTINATION = "destination" +BACKBLAST_EMAIL_SEND = "email_send" +BACKBLAST_EDIT_BUTTON = "edit-backblast" +BACKBLAST_EDIT_CALLBACK_ID = "backblast-edit-id" +BACKBLAST_DUPLICATE_WARNING = "backblast-duplicate-warning" +BACKBLAST_NEW_BUTTON = "new-backblast" +BACKBLAST_OP = "original_poster" +BACKBLAST_STRAVA_BUTTON = "strava-button" +BACKBLAST_FILE = "boyband_file" +BACKBLAST_SELECT_CALLBACK_ID = "backblast-select-id" +BACKBLAST_FILL_SELECT = "backblast-fill-select" +BACKBLAST_NEW_BLANK_BUTTON = "new-blank-backblast" +BACKBLAST_EVENT_TYPE = "event_type" +BACKBLAST_LOCATION = "backblast_location" +BACKBLAST_INFO = "backblast_info" +SECRET_MENU_UPDATE_CANVAS = "canvas-action" +SECRET_MENU_MAKE_ADMIN = "make-admin" +BACKBLAST_CALLBACK_ID_LEGACY = "backblast-id-legacy" +BACKBLAST_EDIT_CALLBACK_ID_LEGACY = "backblast-edit-id-legacy" +BACKBLAST_EDIT_BUTTON_LEGACY = "edit-backblast-legacy" +SECRET_MENU_TRIGGER_MAP_REVALIDATION = "trigger-map-revalidation" +BACKBLAST_SHORTCUT = "backblast_shortcut" +PREBLAST_SHORTCUT = "preblast_shortcut" +CALENDAR_SHORTCUT = "calendar_shortcut" +SETTINGS_SHORTCUT = "settings_shortcut" +TAG_ACHIEVEMENT_SHORTCUT = "tag_achievement_shortcut" +OPEN_CALENDAR_IMAGE_BUTTON = "open-calendar-image-button" +SECRET_MENU_REFRESH_SLACK_USERS = "refresh-slack-users" +ALREADY_POSTED = "already_posted" +SECRET_MENU_BACKBLAST_REMINDERS = "secret-menu-backblast-reminders" +SECRET_MENU_UPDATE_BOT_TOKEN = "secret-menu-update-bot-token" +CALENDAR_HOME_ASSIGN_Q_USER = "calendar-home-assign-q-user" +CALENDAR_HOME_ASSIGN_Q_CO_QS = "calendar-home-assign-q-co-qs" +HOME_ASSIGN_Q_CALLBACK_ID = "home-assign-q-id" +SECRET_MENU_SEND_ADMIN_ANNOUNCEMENT = "secret-menu-send-admin-announcement" +CONFIG_HELP_MENU = "help_menu_config" +CALENDAR_MANAGE_SERIES_AO = "calendar_manage_series_ao" +MSG_EVENT_BACKBLAST_ALREADY_BUTTON = "event_backblast_already_button_dm" +CONFIG_AUTOMATED_PREBLAST = "automated_preblast" +CONFIG_AUTOMATED_PREBLAST_TIME = "automated_preblast_time" +CONFIG_HC_ANNOUNCE_OPTION = "hc_announce_option" +CONFIG_HC_ANNOUNCE_TARGETS = "hc_announce_targets" +SETTINGS_BUTTON = "settings_button" +DEBUG_ID = "debug_id" +DEBUG = "debug" +PREBLAST_CALLBACK_ID = "preblast-id" +PREBLAST_TITLE = "title" +PREBLAST_AO = "The_AO" +PREBLAST_DATE = "date" +PREBLAST_Q = "the_q" +PREBLAST_WHY = "the_why" +PREBLAST_COUPONS = "coupons" +PREBLAST_FNGS = "fngs" +PREBLAST_MOLESKIN = "moleskin" +PREBLAST_DESTINATION = "destination" +PREBLAST_TIME = "time" +PREBLAST_NEW_BUTTON = "new-preblast" +PREBLAST_EDIT_BUTTON = "edit-preblast" +PREBLAST_EDIT_CALLBACK_ID = "preblast-edit-id" +PREBLAST_OP = "preblast_original_poster" +PREBLAST_CALLBACK_ID_LEGACY = "preblast-id-legacy" +PREBLAST_EDIT_CALLBACK_ID_LEGACY = "preblast-edit-id-legacy" +PREBLAST_EDIT_BUTTON_LEGACY = "edit-preblast-legacy" +SUBMIT_MODAL_EXTERNAL_ID = "submit_modal" +DEBUG_FORM_EXTERNAL_ID = "debug_form" +DB_ADMIN_LONG_RUN_SECONDS = "long_run_seconds" +DB_ADMIN_LONG_RUN_CALLBACK_ID = "db-admin-long-run-id" +SECRET_MENU_LONG_RUN = "secret-menu-long-run" +PAXMINER_MAPPING = "paxminer_mapping" +EVENT_PREBLAST_START_TIME = "event_preblast_start_time" +PREBLAST_FILL_BACKBLAST_BUTTON = "fill-backblast" +NEW_PREBLAST_BUTTON = "new-preblast-button" +BACKBLAST_NOQ_SELECT = "backblast-noq-select" +BACKBLAST_FILL_BUTTON = "backblast-fill-button" +BACKBLAST_OPTIONS = "backblast_options" +PREBLAST_OVERFLOW_EDIT = "preblast-overflow-edit" +PREBLAST_OVERFLOW_NEW = "preblast-overflow-new" +PREBLAST_OVERFLOW_FILL_BACKBLAST = "preblast-overflow-fill-backblast" +PREBLAST_OVERFLOW_ACTION = "preblast-overflow-action" +BACKBLAST_SEND_OPTIONS = "backblast_send_options" +SECRET_MENU_SEND_AUTO_PREBLASTS = "secret-menu-send-auto-preblasts" +SECRET_MENU_REFRESH_CACHE = "secret-menu-refresh-cache" +CONFIG_SCHEDULED_PREBLAST_TIME = "scheduled_preblast_time" +REGION_DEFAULT_PV_FILTERS = "region_default_pv_filters" +CONFIG_EMERGENCY_INFO = "config_emergency_info" +EMERGENCY_LOCAL_USER_SELECT = "emergency_local_user_select" +EMERGENCY_DR_USER_SELECT = "emergency_dr_user_select" +EVENT_CLOSE_HOME_CALLBACK_ID = "event-close-home-id" +EVENT_PREBLAST_IMAGE = "event_preblast_image" +MISSING_BACKBLASTS_BUTTON = "missing-backblasts-button" +MISSING_BACKBLASTS_CALLBACK_ID = "missing-backblasts-id" +MISSING_BACKBLASTS_AO_FILTER = "missing-backblasts-ao-filter" +MISSING_BACKBLASTS_EVENT = "missing-backblasts-event" + +CONFIG_EMAIL_ENABLE = "email_enable" +CONFIG_EMAIL_SHOW_OPTION = "email_show" +CONFIG_EMAIL_SERVER = "email_server" +CONFIG_EMAIL_PORT = "email_port" +CONFIG_EMAIL_FROM = "email_from" +CONFIG_EMAIL_TO = "email_to" +CONFIG_EMAIL_PASSWORD = "email_password" +CONFIG_POSTIE_ENABLE = "postie_enable" +CONFIG_CALLBACK_ID = "config-id" +CONFIG_PAXMINER_DB = "paxminer_db" +CONFIG_PAXMINER_DB_OTHER = "paxminer_db_other" +CONFIG_PASSWORD_CONTEXT = "password_context" +CONFIG_POSTIE_CONTEXT = "postie_context" +CONFIG_EDITING_LOCKED = "editing_locked" +CONFIG_DEFAULT_DESTINATION = "default_destination" +CONFIG_DESTINATION_CHANNEL = "destination_channel" +CONFIG_DEFAULT_PREBLAST_DESTINATION = "default_preblast_destination" +CONFIG_PREBLAST_DESTINATION_CHANNEL = "preblast_destination_channel" +CONFIG_BACKBLAST_MOLESKINE_TEMPLATE = "backblast_moleskine_template" +CONFIG_PREBLAST_MOLESKINE_TEMPLATE = "preblast_moleskine_template" +CONFIG_ENABLE_STRAVA = "enable_strava" +CONFIG_CUSTOM_FIELDS = "custom_fields_submenu" +CONFIG_GENERAL = "general" +CONFIG_SPECIAL_EVENTS = "special_events" +CONFIG_GENERAL_CALLBACK_ID = "config-general-id" +CONFIG_EMAIL = "email" +CONFIG_EMAIL_CALLBACK_ID = "config-email-id" +CONFIG_WELCOME_MESSAGE = "welcome_message" +CONFIG_PAXMINER = "config_paxminer" +CONFIG_PAXMINER_1STF_CHANNEL = "paxminer_1stf_channel" +CONFIG_PAXMINER_ENABLE_REPORTS = "paxminer_enable_reports" +CONFIG_PAXMINER_SCRAPE_CHANNELS = "paxminer_scrape_channels" +CONFIG_PAXMINER_CALLBACK_ID = "config-paxminer-id" +CONFIG_CALENDAR = "calendar" +CONFIG_PREBLAST_REMINDER_DAYS = "preblast_reminder_days" +CONFIG_PREBLAST_REMINDER_TIME = "preblast_reminder_time" +CONFIG_BACKBLAST_REMINDER_DAYS = "backblast_reminder_days" +CONFIG_BOT_LOG_CHANNEL = "bot_log_channel" +CONFIG_SLT = "slt" +SLT_SELECT = "slt-select" +CONFIG_SLT_CALLBACK_ID = "config-slt-id" +CONFIG_NEW_POSITION = "new_position" +CONFIG_EDIT_POSITIONS = "edit_positions" +POSITION_EDIT_DELETE = "position-edit-delete" +EDIT_DELETE_POSITION_CALLBACK_ID = "edit-delete-position-id" +EDIT_POSITION_CALLBACK_ID = "edit-position-id" +SLT_LEVEL_SELECT = "slt-level-select" +CONFIG_NEW_POSITION_NAME = "new_position_name" +CONFIG_NEW_POSITION_DESCRIPTION = "new_position_description" +NEW_POSITION_CALLBACK_ID = "new-position-id" +PAXMINER_MIGRATION_REGION = "paxminer_migration_region" +CONFIG_CONNECT = "config_connect" +CALENDAR_CONFIG_Q_LINEUP = "calendar_config_q_lineup" +CALENDAR_CONFIG_GENERAL_CALLBACK_ID = "calendar-config-general-id" +CONFIG_USER_SETTINGS = "user_settings" +CONFIG_REPORTING = "reporting" + +WELCOME_MESSAGE_CONFIG_CALLBACK_ID = "welcome-message-config-id" +WELCOME_DM_TEMPLATE = "welcome-dm-template" +WELCOME_DM_ENABLE = "welcome_dm_enable" +WELCOME_CHANNEL_ENABLE = "welcome_channel_enable" +WELCOME_CHANNEL = "welcome_channel" + +STRAVA_CONNECT_BUTTON = "strava-connect" +STRAVA_ACTIVITY_BUTTON = "strava-activity" +STRAVA_CALLBACK_ID = "strava-id" +STRAVA_ACTIVITY_TITLE = "strava-title" +STRAVA_ACTIVITY_DESCRIPTION = "strava-description" +STRAVA_ACTIVITY_METADATA = "strava-metadata" +STRAVA_MODIFY_CALLBACK_ID = "strava-modify-id" + +STRAVA_ACTIVITY_ID = "strava-activity-id" +STRAVA_CHANNEL_ID = "strava-channel-id" +STRAVA_BACKBLAST_TS = "strava-ts" +STRAVA_BACKBLAST_TITLE = "strava-title" +STRAVA_BACKBLAST_MOLESKINE = "strava-moleskine" + +CUSTOM_FIELD_ENABLE = "custom_field_enable_disable" +CUSTOM_FIELD_EDIT = "custom_field_edit" +CUSTOM_FIELD_ADD = "custom_field_add" +CUSTOM_FIELD_DELETE = "custom_field_delete" +CUSTOM_FIELD_ADD_NAME = "custom_field_add_name" +CUSTOM_FIELD_ADD_TYPE = "custom_field_add_type" +CUSTOM_FIELD_ADD_OPTIONS = "custom_field_add_options" +CUSTOM_FIELD_ADD_CALLBACK_ID = "custom_field_add_id" +CUSTOM_FIELD_MENU_CALLBACK_ID = "custom_field_menu_id" +CUSTOM_FIELD_PREFIX = "custom_field_" +CUSTOM_FIELD_ADD_FORM = "custom_field_add_id" + +ERROR_FORM_MESSAGE = "error_form_message" +LOADING_ID = "loading_id" + +ACHIEVEMENT_CALLBACK_ID = "achievement-id" +ACHIEVEMENT_SELECT = "achievement-select" +ACHIEVEMENT_PAX = "achievement-pax" +ACHIEVEMENT_DATE = "achievement-date" + +# Achievement config/management actions +CONFIG_ACHIEVEMENTS = "config_achievements" +ACHIEVEMENT_CONFIG_CALLBACK_ID = "achievement-config-id" +ACHIEVEMENT_CONFIG_ENABLE = "achievement-config-enable" +ACHIEVEMENT_CONFIG_CHANNEL = "achievement-config-channel" +ACHIEVEMENT_CONFIG_NEW_BTN = "achievement-config-new-btn" +ACHIEVEMENT_CONFIG_MANAGE_BTN = "achievement-config-manage-btn" +ACHIEVEMENT_NEW_CALLBACK_ID = "achievement-new-id" +ACHIEVEMENT_MANAGE_CALLBACK_ID = "achievement-manage-id" +ACHIEVEMENT_MANAGE_OVERFLOW = "achievement-manage-overflow" +ACHIEVEMENT_TAG_CALLBACK_ID = "achievement-tag-id" + +CONFIG_WEASELBOT = "config_weaselbot" +WEASELBOT_ENABLE_FEATURES = "weaselbot_enable_features" +WEASELBOT_ACHIEVEMENT_CHANNEL = "achievement_channel" +WEASELBOT_KOTTER_CHANNEL = "kotter_channel" +WEASELBOT_KOTTER_WEEKS = "kotter_weeks" +WEASELBOT_KOTTER_REMOVE_WEEKS = "kotter_remove_weeks" +WEASELBOT_HOME_AO_WEEKS = "home_ao_weeks" +WEASELBOT_Q_WEEKS = "q_weeks" +WEASELBOT_Q_POSTS = "q_posts" +WEASELBOT_CONFIG_CALLBACK_ID = "weaselbot-config-id" + +CALENDAR_CONFIG_GENERAL = "calendar_config_general" +CALENDAR_ADD_SINGLE_EVENT = "calendar_add_single_event" +CALENDAR_EDIT_SINGLE_EVENT = "calendar_edit_single_event" +CALENDAR_DELETE_SINGLE_EVENT = "calendar_delete_single_event" +CALENDAR_ADD_SERIES = "calendar_add_series" +CALENDAR_EDIT_SERIES = "calendar_edit_series" +CALENDAR_DELETE_SERIES = "calendar_delete_series" +CALENDAR_ADD_AO = "calendar_add_ao" +CALENDAR_EDIT_AO = "calendar_edit_ao" +CALENDAR_DELETE_AO = "calendar_delete_ao" +CALENDAR_ADD_LOCATION = "calendar_add_location" +CALENDAR_ADD_LOCATION_LAT = "calendar_add_location_lat" +CALENDAR_ADD_LOCATION_LON = "calendar_add_location_lon" +CALENDAR_CONFIG_CALLBACK_ID = "calendar-config-id" +CALENDAR_ADD_AO_NAME = "calendar_add_ao_name" +CALENDAR_ADD_AO_DESCRIPTION = "calendar_add_ao_description" +CALENDAR_ADD_AO_CHANNEL = "calendar_add_ao_channel" +CALENDAR_ADD_AO_LOCATION = "calendar_add_ao_location" +CALENDAR_ADD_AO_TYPE = "calendar_add_ao_type" +CALENDAR_ADD_AO_LOGO = "calendar_add_ao_logo" +CALENDAR_ADD_LOCATION_NAME = "calendar_add_location_name" +CALENDAR_ADD_LOCATION_DESCRIPTION = "calendar_add_location_description" +CALENDAR_ADD_LOCATION_GOOGLE = "calendar_add_location_google" +ADD_LOCATION_CALLBACK_ID = "add-location-id" +ADD_AO_CALLBACK_ID = "add-ao-id" +CALENDAR_ADD_SERIES_NAME = "calendar_add_series_name" +CALENDAR_ADD_SERIES_DESCRIPTION = "calendar_add_series_description" +CALENDAR_ADD_SERIES_AO = "calendar_add_series_ao" +CALENDAR_ADD_SERIES_LOCATION = "calendar_add_series_location" +CALENDAR_ADD_SERIES_TYPE = "calendar_add_series_type" +CALENDAR_ADD_SERIES_START_DATE = "calendar_add_series_start_date" +CALENDAR_ADD_SERIES_END_DATE = "calendar_add_series_end_date" +CALENDAR_ADD_SERIES_START_TIME = "calendar_add_series_start_time" +CALENDAR_ADD_SERIES_END_TIME = "calendar_add_series_end_time" +CALENDAR_ADD_SERIES_FREQUENCY = "calendar_add_series_frequency" +CALENDAR_ADD_SERIES_INTERVAL = "calendar_add_series_interval" +CALENDAR_ADD_SERIES_INDEX = "calendar_add_series_index" +CALENDAR_ADD_SERIES_DOW = "calendar_add_series_dow" +CALENDAR_ADD_SERIES_HIGHLIGHT = "calendar_add_series_highlight" +CALENDAR_ADD_SERIES_OPTIONS = "calendar_add_series_options" +ADD_SERIES_CALLBACK_ID = "add-series-id" +EDIT_DELETE_SERIES_CALLBACK_ID = "edit-delete-series-id" +SERIES_EDIT_DELETE = "series-edit-delete" +CALENDAR_EDIT_LOCATION = "calendar_edit_location" +LOCATION_EDIT_DELETE = "location-edit-delete" +EDIT_DELETE_LOCATION_CALLBACK_ID = "edit-delete-location-id" +AO_EDIT_DELETE = "ao-edit-delete" +EDIT_DELETE_AO_CALLBACK_ID = "edit-delete-ao-id" +CALENDAR_HOME_CALLBACK_ID = "calendar-home-id" +CALENDAR_HOME_EVENT = "calendar-home-event" +CALENDAR_HOME_AO_FILTER = "calendar-home-ao-filter" +CALENDAR_HOME_EVENT_TYPE_FILTER = "calendar-home-type-filter" +CALENDAR_HOME_Q_FILTER = "calendar-home-q-filter" +CALENDAR_ADD_EVENT_AO = "calendar-add-event-ao" +CALENDAR_HOME_DATE_FILTER = "calendar-home-date-filter" +CALENDAR_MANAGE_LOCATIONS = "calendar-manage-locations" +CALENDAR_MANAGE_AOS = "calendar-manage-aos" +CALENDAR_MANAGE_SERIES = "calendar-manage-series" +CALENDAR_MANAGE_EVENTS = "calendar-manage-events" +CALENDAR_ADD_AO_NEW_LOCATION = "calendar-add-ao-new-location" +EVENT_PREBLAST_TAKE_Q = "event-preblast-take-q" +EVENT_PREBLAST_HC = "event-preblast-hc" +EVENT_PREBLAST_UN_HC = "event-preblast-un-hc" +EVENT_PREBLAST_TITLE = "event-preblast-title" +EVENT_PREBLAST_LOCATION = "event-preblast-location" +EVENT_PREBLAST_MOLESKINE_EDIT = "event-preblast-moleskine-edit" +EVENT_PREBLAST_CALLBACK_ID = "event-preblast-id" +EVENT_PREBLAST_SEND_OPTIONS = "event-preblast-send-options" +EVENT_PREBLAST_REMOVE_Q = "event-preblast-remove-q" +FILTER_OPEN_Q = "filter-open-q" +FILTER_MY_EVENTS = "filter-my-events" +FILTER_NEARBY_REGIONS = "filter-nearby-regions" +EVENT_PREBLAST_COQS = "event-preblast-coqs" +EVENT_PREBLAST_EDIT = "event-preblast-edit" +EVENT_PREBLAST_HC_UN_HC = "event-preblast-hc-un-hc" +EVENT_PREBLAST_POST_CALLBACK_ID = "event-preblast-post-id" +CALENDAR_ADD_SERIES_TAG = "calendar-add-series-tag" +EVENT_PREBLAST_TAG = "event-preblast-tag" +EVENT_PREBLAST_UPDATE_MODE = "event-preblast-update-mode" +EVENT_PREBLAST_SELECT = "event-preblast-select" +EVENT_PREBLAST_SELECT_CALLBACK_ID = "event-preblast-select-id" +EVENT_PREBLAST_NEW_BUTTON = "event-preblast-new" +EVENT_PREBLAST_FILL_BUTTON = "event-preblast-fill-button" +EVENT_PREBLAST_NOQ_SELECT = "event-preblast-noq-select" +OPEN_CALENDAR_BUTTON = "open-calendar" +CALENDAR_ADD_LOCATION_STREET = "calendar-add-location-street" +CALENDAR_ADD_LOCATION_STREET2 = "calendar-add-location-street2" +CALENDAR_ADD_LOCATION_CITY = "calendar-add-location-city" +CALENDAR_ADD_LOCATION_STATE = "calendar-add-location-state" +CALENDAR_ADD_LOCATION_ZIP = "calendar-add-location-zip" +CALENDAR_ADD_LOCATION_COUNTRY = "calendar-add-location-country" +SPECIAL_EVENTS_INFO_CANVAS_CHANNEL = "special_events_info_canvas_channel" +LINEUP_SIGNUP_BUTTON = "lineup-signup-button" +SECRET_MENU_GENERATE_EVENT_INSTANCES = "generate-event-instances" +USER_OPTION_LOAD = "user_option_load" +REGION_OPTION_LOAD = "region_option_load" + +MSG_EVENT_PREBLAST_TITLE = "event_preblast_title_dm" +MSG_EVENT_PREBLAST_MOLESKINE = "event_preblast_moleskine_dm" +MSG_EVENT_PREBLAST_SUBMIT = "event_preblast_submit_dm" +MSG_EVENT_PREBLAST_BUTTON = "event_preblast_button_dm" +MSG_EVENT_BACKBLAST_BUTTON = "event_backblast_button_dm" + +REGION_NAME = "region_name" +REGION_DESCRIPTION = "region_description" +REGION_LOGO = "region_logo" +REGION_CALLBACK_ID = "region-id" +REGION_WEBSITE = "region_website" +REGION_EMAIL = "region_email" +REGION_TWITTER = "region_twitter" +REGION_INSTAGRAM = "region_instagram" +REGION_FACEBOOK = "region_facebook" +REGION_INFO_BUTTON = "region_info" +REGION_ADMINS = "region_admins" + +SPECIAL_EVENTS_ENABLED = "special_events_enabled" +SPECIAL_EVENTS_CHANNEL = "special_events_channel" +SPECIAL_EVENTS_POST_DAYS = "special_events_post_days" +SPECIAL_EVENTS_CALLBACK_ID = "special-events-id" + +DB_ADMIN_CALLBACK_ID = "db-admin-id" +DB_ADMIN_TEXT = "db-admin-text" +DB_ADMIN_UPGRADE = "db-admin-upgrade" +DB_ADMIN_DOWNGRADE = "db-admin-downgrade" +DB_ADMIN_RESET = "db-admin-reset" +SECRET_MENU_CALENDAR_IMAGES = "secret-menu-calendar-images" +SECRET_MENU_PAXMINER_MIGRATION = "secret-menu-paxminer-migration" +SECRET_MENU_PAXMINER_MIGRATION_ALL = "secret-menu-paxminer-migration-all" +SECRET_MENU_MAKE_ORG = "secret-menu-make-org" +SECRET_MENU_AO_LINEUPS = "secret-menu-ao-lineups" +SECRET_MENU_PREBLAST_REMINDERS = "secret-menu-preblast-reminders" + +CALENDAR_ADD_SERIES_PREBLAST = "calendar-add-series-preblast" +EVENT_INSTANCE_EDIT_DELETE = "event-instance-edit-delete" + +CONFIG_DOWNRANGE = "config_downrange" +DOWNRANGE_CALLBACK_ID = "downrange-settings-id" +DOWNRANGE_REGION_SELECT = "downrange_region_select" +DOWNRANGE_INVITE_SHARING = "downrange_invite_sharing" +DOWNRANGE_INVITE_LINK = "downrange_invite_link" +DOWNRANGE_INTRO_TEXT = "downrange_intro_text" +DOWNRANGE_INVITE_REQUEST_BUTTON = "downrange_invite_request" +DOWNRANGE_INVITE_APPROVE_BUTTON = "downrange_invite_approve" +DOWNRANGE_INVITE_DENY_BUTTON = "downrange_invite_deny" +DOWNRANGE_INVITE_LINK_BROKEN_BUTTON = "downrange_invite_link_broken" +DOWNRANGE_CHANNEL_POSTING = "downrange_channel_posting" +DOWNRANGE_CHANNEL = "downrange_channel" +DOWNRANGE_INVITE_MARK_DONE_BUTTON = "downrange_invite_mark_done" + +HOME_REGION_NUDGE_SWITCH_BUTTON = "home-region-nudge-switch" +HOME_REGION_NUDGE_DISMISS_BUTTON = "home-region-nudge-dismiss" +HOME_REGION_NUDGE_OPT_OUT_BUTTON = "home-region-nudge-opt-out" + +NEARBY_EVENTS_OPEN = "nearby-events-open" +NEARBY_EVENTS_DISTANCE = "nearby-events-distance" +NEARBY_EVENTS_SORT = "nearby-events-sort" +NEARBY_EVENTS_HC = "nearby-events-hc" +NEARBY_EVENTS_UN_HC = "nearby-events-un-hc" +NEARBY_EVENTS_CALLBACK_ID = "nearby-events-callback" diff --git a/apps/slackbot/utilities/slack/forms.py b/apps/slackbot/utilities/slack/forms.py new file mode 100644 index 00000000..53c3c71f --- /dev/null +++ b/apps/slackbot/utilities/slack/forms.py @@ -0,0 +1,1048 @@ +from utilities import constants +from utilities.slack import actions, orm + +UNSCHEDULED_BACKBLAST_BLOCKS = [ + orm.InputBlock( + label="Workout Date", + action=actions.BACKBLAST_DATE, + optional=False, + element=orm.DatepickerElement(placeholder="Select the date..."), + ), + orm.InputBlock( + label="Event Type", + action=actions.BACKBLAST_EVENT_TYPE, + optional=False, + element=orm.StaticSelectElement(placeholder="Select the event type..."), + ), + orm.InputBlock( + label="Location", + action=actions.BACKBLAST_LOCATION, + optional=True, + element=orm.StaticSelectElement(placeholder="Select the location..."), + ), + orm.InputBlock( + label="The AO", + action=actions.BACKBLAST_AO, + optional=False, + element=orm.StaticSelectElement(placeholder="Select the AO..."), + ), +] + +BACKBLAST_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Title", + action=actions.BACKBLAST_TITLE, + optional=False, + element=orm.PlainTextInputElement(placeholder="Enter a workout title..."), + ), + orm.InputBlock( + label="Upload a boyband", + element=orm.FileInputElement( + max_files=1, + filetypes=[ + "png", + "jpg", + "heic", + "bmp", + ], + ), + action=actions.BACKBLAST_FILE, + optional=True, + ), + orm.SectionBlock( + label="This is where the AO name, Event Types, and Workout Date will be filled in automatically.", + action=actions.BACKBLAST_INFO, + ), + orm.InputBlock( + label="The Q", + action=actions.BACKBLAST_Q, + optional=False, + element=orm.UsersSelectElement(placeholder="Select the Q..."), + ), + orm.InputBlock( + label="The CoQ(s), if any", + action=actions.BACKBLAST_COQ, + optional=True, + element=orm.MultiUsersSelectElement(placeholder="Select the CoQ(s)..."), + ), + orm.InputBlock( + label="The PAX", + action=actions.BACKBLAST_PAX, + optional=False, + element=orm.MultiUsersSelectElement(placeholder="Select the PAX..."), + ), + orm.InputBlock( + label="PAX not in this Slack space", + action=actions.USER_OPTION_LOAD, + optional=True, + element=orm.MultiExternalSelectElement(placeholder="Type to search..."), + hint="To filter by home region, include the region in parentheses after their name, e.g. 'money (wash)' -> Moneyball (WashMo).", # noqa: E501 + ), + orm.InputBlock( + label="List untaggable PAX, separated by commas (not FNGs)", + action=actions.BACKBLAST_NONSLACK_PAX, + optional=True, + element=orm.PlainTextInputElement( + placeholder="Enter untaggable PAX...", + ), + ), + orm.InputBlock( + label="List FNGs, separated by commas", + action=actions.BACKBLAST_FNGS, + optional=True, + element=orm.PlainTextInputElement(placeholder="Enter FNGs..."), + ), + orm.InputBlock( + label="Total PAX Count", + action=actions.BACKBLAST_COUNT, + optional=True, + element=orm.PlainTextInputElement(placeholder="Total PAX count including FNGs"), + hint="If left blank, this will be calculated automatically from the fields above.", + ), + orm.InputBlock( + label="The Moleskine", + action=actions.BACKBLAST_MOLESKIN, + optional=False, + element=orm.RichTextInputElement(), + ), + orm.ContextBlock( + element=orm.ContextElement( + initial_value="*Note:* anything you put here may be visible to the public, through dashboards, websites, etc. We encourage you to post private COT items (prayer requests, etc) in a separate post or reply to the backblast.", # noqa: E501 + ), + ), + orm.InputBlock( + label="Backblast Options", + action=actions.BACKBLAST_OPTIONS, + optional=True, + element=orm.CheckboxInputElement( + options=orm.as_selector_options( + names=[ + "Exclude stats from PAX Vault", + ], + values=[ + "exclude_from_pax_vault", + ], + ) + ), + ), + orm.DividerBlock(), + # orm.InputBlock( + # label="Choose where to post this", + # action=actions.BACKBLAST_DESTINATION, + # optional=False, + # element=orm.StaticSelectElement(placeholder="Select a destination..."), + # ), + orm.InputBlock( + label="Email Backblast (to Wordpress, etc)", + action=actions.BACKBLAST_EMAIL_SEND, + optional=False, + element=orm.RadioButtonsElement( + options=orm.as_selector_options(names=["Send Email", "Don't Send Email"], values=["yes", "no"]), + initial_value="yes", + ), + ), + orm.InputBlock( + label="When to post backblast?", + action=actions.BACKBLAST_SEND_OPTIONS, + optional=False, + element=orm.RadioButtonsElement( + options=orm.as_selector_options( + names=["Send now", "Save and send later"], + ), + initial_value="Send now", + ), + ), + ] +) + +PREBLAST_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Title", + action=actions.PREBLAST_TITLE, + optional=False, + element=orm.PlainTextInputElement(placeholder="Enter a workout title..."), + ), + orm.InputBlock( + label="The AO", + action=actions.PREBLAST_AO, + optional=False, + element=orm.ChannelsSelectElement(placeholder="Select the AO..."), + ), + orm.InputBlock( + label="Workout Date", + action=actions.PREBLAST_DATE, + optional=False, + element=orm.DatepickerElement( + placeholder="Select the date...", + ), + ), + orm.InputBlock( + label="Workout Time", + action=actions.PREBLAST_TIME, + optional=False, + element=orm.TimepickerElement(), + ), + orm.InputBlock( + label="The Q", + action=actions.PREBLAST_Q, + optional=False, + element=orm.UsersSelectElement(placeholder="Select the Q..."), + ), + orm.InputBlock( + label="Coupons?", + action=actions.PREBLAST_COUPONS, + optional=True, + element=orm.PlainTextInputElement( # TODO: change to radio buttons or checkboxes + placeholder="Coupons or not?", + ), + ), + orm.InputBlock( + label="Moleskine", + action=actions.PREBLAST_MOLESKIN, + optional=True, + element=orm.RichTextInputElement(), + ), + orm.DividerBlock(), + orm.InputBlock( + label="Choose where to post this", + action=actions.PREBLAST_DESTINATION, + optional=False, + element=orm.StaticSelectElement(placeholder="Select a destination..."), + ), + ] +) + +CONFIG_FORM = orm.BlockView( + [ + orm.ActionsBlock( + elements=[ + orm.ButtonElement( + label=":date: Calendar Settings", + action=actions.CONFIG_CALENDAR, + ), + orm.ButtonElement( + label=":world_map: Region Info", + action=actions.REGION_INFO_BUTTON, + ), + orm.ButtonElement( + label=":bust_in_silhouette: User Settings", + action=actions.CONFIG_USER_SETTINGS, + ), + orm.ButtonElement( + label=":hospital: Emergency Info Access", + action=actions.CONFIG_EMERGENCY_INFO, + ), + orm.ButtonElement( + label=":grey_question: Help Menu", + action=actions.CONFIG_HELP_MENU, + ), + orm.ButtonElement( + label=":speech_balloon: Welcomebot Settings", + action=actions.CONFIG_WELCOME_MESSAGE, + ), + orm.ButtonElement( + label=":chart_with_upwards_trend: Reporting Settings", + action=actions.CONFIG_REPORTING, + ), + # orm.ButtonElement( + # label=":newspaper: Region Canvas Settings", + # action=actions.CONFIG_SPECIAL_EVENTS, + # ), + orm.ButtonElement( + label=":classical_building: SLT Settings", + action=actions.CONFIG_SLT, + ), + orm.ButtonElement( + label=":gear: Backblast & Preblast Settings", + action=actions.CONFIG_GENERAL, + ), + orm.ButtonElement( + label=":email: Backblast Email Settings", + action=actions.CONFIG_EMAIL, + ), + orm.ButtonElement( + label=":bar_chart: Custom Field Settings", + action=actions.CONFIG_CUSTOM_FIELDS, + ), + orm.ButtonElement( + label=":computer: Paxminer Mapping", + action=actions.PAXMINER_MAPPING, + ), + orm.ButtonElement( + label=":airplane: Downrange", + action=actions.CONFIG_DOWNRANGE, + ), + ], + ), + ] +) + +ACHIEVEMENT_BUTTON = orm.ButtonElement( + label=":sports_medal: Achievement Settings", + action=actions.CONFIG_ACHIEVEMENTS, +) + +CONFIG_NO_ORG_FORM = orm.BlockView( + [ + orm.ActionsBlock( + elements=[ + orm.ButtonElement( + label=":date: Migration Settings", + action=actions.CONFIG_CONNECT, + ), + orm.ButtonElement( + label=":bust_in_silhouette: User Settings", + action=actions.CONFIG_USER_SETTINGS, + ), + orm.ButtonElement( + label=":speech_balloon: Welcomebot Settings", + action=actions.CONFIG_WELCOME_MESSAGE, + ), + orm.ButtonElement( + label=":gear: Backblast & Preblast Settings", + action=actions.CONFIG_GENERAL, + ), + orm.ButtonElement( + label=":email: Backblast Email Settings", + action=actions.CONFIG_EMAIL, + ), + orm.ButtonElement( + label=":bar_chart: Custom Field Settings", + action=actions.CONFIG_CUSTOM_FIELDS, + ), + ], + ), + ] +) + +CONFIG_EMAIL_FORM = orm.BlockView( + [ + orm.InputBlock( + label="Backblast Email", + action=actions.CONFIG_EMAIL_ENABLE, + optional=False, + element=orm.RadioButtonsElement( + initial_value="disable", + options=orm.as_selector_options(names=["Enable Email", "Disable Email"], values=["enable", "disable"]), + ), + ), + orm.InputBlock( + label="Show email option in form?", + action=actions.CONFIG_EMAIL_SHOW_OPTION, + optional=False, + element=orm.RadioButtonsElement( + initial_value="no", + options=orm.as_selector_options(names=["Show", "Don't Show"], values=["yes", "no"]), + ), + ), + orm.InputBlock( + label="Email Server", + action=actions.CONFIG_EMAIL_SERVER, + optional=False, + element=orm.PlainTextInputElement(initial_value="smtp.gmail.com"), + ), + orm.InputBlock( + label="Email Port", + action=actions.CONFIG_EMAIL_PORT, + optional=False, + element=orm.PlainTextInputElement(initial_value="587"), + ), + orm.InputBlock( + label="Email From Address", + action=actions.CONFIG_EMAIL_FROM, + optional=False, + element=orm.PlainTextInputElement(initial_value="example_sender@gmail.com"), + ), + orm.InputBlock( + label="Email Password", + action=actions.CONFIG_EMAIL_PASSWORD, + optional=False, + element=orm.PlainTextInputElement(initial_value="example_pwd_123"), + hint="If using gmail, you must use an App Password (https://support.google.com/accounts/answer/185833)." + "Your password will be stored encrypted - however, it is STRONGLY recommended that you use a non-personal" + "email address and password for this purpose, as security cannot be guaranteed.", + ), + orm.InputBlock( + label="Email To Address", + action=actions.CONFIG_EMAIL_TO, + optional=False, + element=orm.PlainTextInputElement(initial_value="example_destination@gmail.com"), + ), + orm.InputBlock( + label="Use Postie formatting for categories?", + action=actions.CONFIG_POSTIE_ENABLE, + optional=False, + element=orm.RadioButtonsElement( + initial_value="no", + options=orm.as_selector_options(names=["Yes", "No"], values=["yes", "no"]), + ), + hint="This will put the AO name as a category for the post, and will put PAX names at the end as tags.", + ), + ] +) + +CONFIG_GENERAL_FORM = orm.BlockView( + [ + orm.InputBlock( + label="Enable Strava Integration?", + action=actions.CONFIG_ENABLE_STRAVA, + optional=False, + element=orm.RadioButtonsElement( + initial_value="no", + options=orm.as_selector_options(names=["Enable", "Disable"], values=["enable", "disable"]), + ), + ), + orm.DividerBlock(), + orm.InputBlock( + label="Lock editing of backblasts?", + action=actions.CONFIG_EDITING_LOCKED, + optional=False, + element=orm.RadioButtonsElement( + initial_value="no", + options=orm.as_selector_options(names=["Yes", "No"], values=["yes", "no"]), + ), + ), + orm.DividerBlock(), + orm.InputBlock( + label="Default Slack channel desination for preblasts", + action=actions.CONFIG_DEFAULT_PREBLAST_DESTINATION, + optional=False, + element=orm.RadioButtonsElement( + initial_value=constants.CONFIG_DESTINATION_AO["value"], + options=orm.as_selector_options( + names=[ + constants.CONFIG_DESTINATION_AO["name"], + # constants.CONFIG_DESTINATION_CURRENT["name"], + constants.CONFIG_DESTINATION_SPECIFIED["name"], + ], + values=[ + constants.CONFIG_DESTINATION_AO["value"], + # constants.CONFIG_DESTINATION_CURRENT["value"], + constants.CONFIG_DESTINATION_SPECIFIED["value"], + ], + ), + ), + ), + orm.InputBlock( + label="Specified Preblast Channel", + action=actions.CONFIG_PREBLAST_DESTINATION_CHANNEL, + optional=True, + element=orm.ChannelsSelectElement(placeholder="Select the channel..."), + hint="Only required if 'Specified Channel' is selected above.", + ), + orm.DividerBlock(), + orm.InputBlock( + label="Default Slack channel desination for backblasts", + action=actions.CONFIG_DEFAULT_DESTINATION, + optional=False, + element=orm.RadioButtonsElement( + initial_value=constants.CONFIG_DESTINATION_AO["value"], + options=orm.as_selector_options( + names=[ + constants.CONFIG_DESTINATION_AO["name"], + # constants.CONFIG_DESTINATION_CURRENT["name"], + constants.CONFIG_DESTINATION_SPECIFIED["name"], + ], + values=[ + constants.CONFIG_DESTINATION_AO["value"], + # constants.CONFIG_DESTINATION_CURRENT["value"], + constants.CONFIG_DESTINATION_SPECIFIED["value"], + ], + ), + ), + ), + orm.InputBlock( + label="Specified Channel", + action=actions.CONFIG_DESTINATION_CHANNEL, + optional=True, + element=orm.ChannelsSelectElement(placeholder="Select the channel..."), + hint="Only required if 'Specified Channel' is selected above.", + ), + orm.DividerBlock(), + orm.InputBlock( + label="Backblast Moleskine Template / Starter", + action=actions.CONFIG_BACKBLAST_MOLESKINE_TEMPLATE, + optional=True, + element=orm.RichTextInputElement(), + ), + orm.InputBlock( + label="Preblast Moleskine Template / Starter", + action=actions.CONFIG_PREBLAST_MOLESKINE_TEMPLATE, + optional=True, + element=orm.RichTextInputElement(), + ), + orm.DividerBlock(), + orm.InputBlock( + label="Preblast Reminder Time (CST)", + action=actions.CONFIG_PREBLAST_REMINDER_TIME, + optional=True, + element=orm.TimepickerElement(), + hint="Reminder DMs will be sent to Qs this time the day before their event. Set this earlier than your automated/scheduled preblast times so Qs have a chance to fill out their own preblast first.", # noqa + ), + orm.InputBlock( + label="Automated Preblast Options", + action=actions.CONFIG_AUTOMATED_PREBLAST, + optional=False, + element=orm.RadioButtonsElement( + options=orm.as_selector_options( + names=["Send for Qs", "Send even if no Q", "Disable"], values=["q_only", "always", "disable"] + ), # noqa: E501 + initial_value="q_only", + ), + ), + orm.InputBlock( + label="Automated Preblast Time (CST)", + action=actions.CONFIG_AUTOMATED_PREBLAST_TIME, + optional=True, + element=orm.TimepickerElement(), + hint="Automated preblasts will be sent this time the day before if one has not been set by the Q.", # noqa + ), + orm.InputBlock( + label="Scheduled Preblast Time (CST)", + action=actions.CONFIG_SCHEDULED_PREBLAST_TIME, + optional=True, + element=orm.TimepickerElement(), + hint="Scheduled preblasts will be sent this time the day before when Q has set a preblast for 'send a day before'.", # noqa + ), + orm.InputBlock( + label="Backblast Reminder Count", + action=actions.CONFIG_BACKBLAST_REMINDER_DAYS, + optional=True, + element=orm.NumberInputElement( + placeholder="Enter the number of days...", is_decimal_allowed=False, min_value=0, max_value=7 + ), + hint="This sets the number of reminders a Q will get until they complete their backblast. If set to 0, no reminders will be sent. Defaults to 5.", # noqa + ), + orm.DividerBlock(), + orm.InputBlock( + label="HC Announcement in Preblast Thread", + action=actions.CONFIG_HC_ANNOUNCE_OPTION, + optional=False, + element=orm.RadioButtonsElement( + initial_value="off", + options=orm.as_selector_options( + names=["Off", "Standard", "Snarky"], + values=["off", "standard", "snarky"], + ), + ), + hint="When enabled, the bot posts a reply in the preblast thread when someone HC's or Un-HC's.", + ), + orm.InputBlock( + label="HC Announcement Target", + action=actions.CONFIG_HC_ANNOUNCE_TARGETS, + optional=False, + element=orm.RadioButtonsElement( + initial_value="both", + options=orm.as_selector_options( + names=["Both HCs and Un-HCs", "HCs Only", "Un-HCs Only"], + values=["both", "hc_only", "unhc_only"], + ), + ), + hint="Select which actions trigger an announcement in the preblast thread.", + ), + orm.DividerBlock(), + orm.InputBlock( + label="Bot Action Log Channel", + action=actions.CONFIG_BOT_LOG_CHANNEL, + element=orm.ChannelsSelectElement(placeholder="Select a channel..."), + optional=True, + hint="When set, the bot will post a log message here whenever a key action occurs (e.g. event edited). If left blank and a log is triggered, the bot will auto-create #nation_bot_logs.", # noqa + ), + ] +) + +WELCOME_MESSAGE_CONFIG_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Enable Welcomebot welcome DMs?", + action=actions.WELCOME_DM_ENABLE, + optional=False, + element=orm.RadioButtonsElement( + initial_value="no", + options=orm.as_selector_options(names=["Enable", "Disable"], values=["enable", "disable"]), + ), + ), + orm.InputBlock( + label="Welcome Message Template", + action=actions.WELCOME_DM_TEMPLATE, + optional=True, + element=orm.RichTextInputElement(), + ), + orm.ContextBlock( + element=orm.ContextElement( + initial_value="*This content will be sent to any new user who joins this Slack workspace.*\n\n" + + "This is a good time to tell an FNG or long-time Slack hold out what they need to know about your region and how you use Slack.\n" # noqa: E501 + + "Who should they reach out to if they have a question? What channels should they join? What does HC mean and " # noqa: E501 + + "how do they do that? Should their Slack handle be their F3 name?", + ), + ), + orm.InputBlock( + label="Enable Welcomebot welcome channel posts?", + action=actions.WELCOME_CHANNEL_ENABLE, + optional=False, + element=orm.RadioButtonsElement( + initial_value="disable", + options=orm.as_selector_options(names=["Enable", "Disable"], values=["enable", "disable"]), + ), + ), + orm.InputBlock( + label="Welcomebot Channel", + action=actions.WELCOME_CHANNEL, + optional=False, + element=orm.ChannelsSelectElement(placeholder="Select the channel..."), + hint="If enabled, this is the channel where welcome messages will be posted.", + ), + ] +) + +STRAVA_ACTIVITY_MODIFY_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Activity Title", + action=actions.STRAVA_ACTIVITY_TITLE, + optional=False, + element=orm.PlainTextInputElement( + initial_value="", + placeholder="Enter a workout title...", + max_length=100, + ), + ), + orm.InputBlock( + label="Activity Description", + action=actions.STRAVA_ACTIVITY_DESCRIPTION, + optional=False, + element=orm.PlainTextInputElement( + initial_value="", + placeholder="Enter a workout description...", + max_length=3000, + multiline=True, + ), + ), + ] +) + +CUSTOM_FIELD_TYPE_MAP = { + "Dropdown": orm.StaticSelectElement(), + "Text": orm.PlainTextInputElement(), + "Number": orm.NumberInputElement(), +} + +CUSTOM_FIELD_ADD_EDIT_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + element=orm.PlainTextInputElement( + initial_value="", + multiline=False, + placeholder="Enter the name of the new field or metric", + max_length=40, + ), + action=actions.CUSTOM_FIELD_ADD_NAME, + label="Field name / metric name", + optional=False, + ), + orm.InputBlock( + element=orm.StaticSelectElement( + options=orm.as_selector_options( + names=CUSTOM_FIELD_TYPE_MAP.keys(), + ), + initial_value="Dropdown", + ), + action=actions.CUSTOM_FIELD_ADD_TYPE, + label="Type of entry", + optional=False, + # dispatch_action=True, TODO: hide dropdown options if not "Dropdown" + ), + orm.InputBlock( + element=orm.PlainTextInputElement( + initial_value=" ", + multiline=False, + placeholder="Enter the options for the dropdown, separated by commas", + max_length=100, + ), + action=actions.CUSTOM_FIELD_ADD_OPTIONS, + label="Dropdown options (only required if 'Dropdown' is selected above)", + optional=True, + hint="Separate options with commas", + ), + ] +) + +LOADING_FORM = orm.BlockView( + blocks=[ + orm.SectionBlock(label=":hourglass: Loading, do not close...", action=actions.LOADING), + orm.ContextBlock( + action="loading_context", + element=orm.ContextElement( + initial_value="If this form does not update after ~10 seconds, an error may have occurred. Please try again.", # noqa: E501 + ), + ), + ] +) + +DEBUG_FORM = orm.BlockView( + blocks=[ + orm.SectionBlock(label=":beetle: Debug Mode", action=actions.DEBUG), + orm.ContextBlock( + action="loading_context", + element=orm.ContextElement( + initial_value="If your call does not end in an updated view, you may have to close this manually.", # noqa: E501 + ), + ), + ] +) + +ERROR_FORM = orm.BlockView( + blocks=[ + orm.SectionBlock(label=":warning: the following error occurred:", action=actions.ERROR_FORM_MESSAGE), + ] +) + +ACHIEVEMENT_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Achievement", + action=actions.ACHIEVEMENT_SELECT, + optional=False, + element=orm.StaticSelectElement(placeholder="Select the achievement..."), + hint="If you don't see the achievement you're looking for, talk to your Weasel Shaker / Tech Q about getting it added!", # noqa: E501 + ), + orm.InputBlock( + label="Select the PAX", + action=actions.ACHIEVEMENT_PAX, + optional=False, + element=orm.MultiUsersSelectElement(placeholder="Select the PAX..."), + ), + orm.InputBlock( + label="Achievement Date", + action=actions.ACHIEVEMENT_DATE, + optional=False, + element=orm.DatepickerElement(placeholder="Select the date..."), + hint="Please use a date in the period the achievement was earned, as some achievements can be earned for several periods.", # noqa: E501 + ), + ] +) + +WEASELBOT_CONFIG_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Which Weaselbot features should be enabled?", + action=actions.WEASELBOT_ENABLE_FEATURES, + element=orm.CheckboxInputElement( + options=orm.as_selector_options( + names=["Achievements", "Kotter Reports"], + values=["achievements", "kotter_reports"], + ) + ), + ), + orm.InputBlock( + label="Which channel should achievements be posted to?", + action=actions.WEASELBOT_ACHIEVEMENT_CHANNEL, + optional=True, + element=orm.ChannelsSelectElement(placeholder="Select the channel..."), + ), + orm.InputBlock( + label="Which user or channel should Kotter Reports be posted to?", + action=actions.WEASELBOT_KOTTER_CHANNEL, + optional=True, + element=orm.ConversationsSelectElement(placeholder="Select the user or channel..."), + hint="Please note that Weaselbot will need to be manually added to private channels if selected.", + ), + orm.InputBlock( + label="How many weeks of no posting should put a PAX on the Kotter Report?", + action=actions.WEASELBOT_KOTTER_WEEKS, + optional=True, + element=orm.NumberInputElement(placeholder="Enter the number of weeks...", is_decimal_allowed=False), + ), + orm.InputBlock( + label="After how many weeks of no posting should a PAX be removed from the Kotter Report?", + action=actions.WEASELBOT_KOTTER_REMOVE_WEEKS, + optional=True, + element=orm.NumberInputElement(placeholder="Enter the number of weeks...", is_decimal_allowed=False), + ), + orm.InputBlock( + label="How many weeks of activity should be used to base a PAX's home AO?", + action=actions.WEASELBOT_HOME_AO_WEEKS, + optional=True, + element=orm.NumberInputElement(placeholder="Enter the number of weeks...", is_decimal_allowed=False), + ), + orm.InputBlock( + label="After how many weeks of no Qing should a PAX be put on the Q list?", + action=actions.WEASELBOT_Q_WEEKS, + optional=True, + element=orm.NumberInputElement(placeholder="Enter the number of weeks...", is_decimal_allowed=False), + ), + orm.InputBlock( + label="What should be the minimum number of posts over that time to be eligible for the Q list?", + action=actions.WEASELBOT_Q_POSTS, + optional=True, + element=orm.NumberInputElement(placeholder="Enter the number of posts...", is_decimal_allowed=False), + ), + ] +) + + +NO_WEASELBOT_CONFIG_FORM = orm.BlockView( + blocks=[ + orm.SectionBlock( + label="Weaselbot and / or PAXMiner doesn't appear to be configured for this Slack workspace. Please follow to get started!", # noqa: E501 + ) + ] +) + +CONFIG_NO_PERMISSIONS_FORM = orm.BlockView( + blocks=[ + orm.ActionsBlock( + elements=[ + orm.ButtonElement( + label=":bust_in_silhouette: User Settings", + action=actions.CONFIG_USER_SETTINGS, + ), + orm.ButtonElement( + label=":hospital: Emergency Info Access", + action=actions.CONFIG_EMERGENCY_INFO, + ), + orm.ButtonElement( + label=":grey_question: Help Menu", + action=actions.CONFIG_HELP_MENU, + ), + orm.ButtonElement( + label=":airplane: Downrange", + action=actions.CONFIG_DOWNRANGE, + ), + ], + ), + orm.SectionBlock( + label="Looking for region or calendar settings? You must be assigned as an admin for your region in order to access these.", # noqa: E501 + ), + ] +) + +CONFIG_NEW_POSITION_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Position Name", + action=actions.CONFIG_NEW_POSITION_NAME, + optional=False, + element=orm.PlainTextInputElement(placeholder="Enter the new position name..."), + ), + orm.InputBlock( + label="Position Description", + action=actions.CONFIG_NEW_POSITION_DESCRIPTION, + optional=False, + element=orm.PlainTextInputElement(placeholder="Enter the new position description..."), + ), + ] +) + +BACKBLAST_LEGACY_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Title", + action=actions.BACKBLAST_TITLE, + optional=False, + element=orm.PlainTextInputElement(placeholder="Enter a workout title..."), + ), + orm.InputBlock( + label="Upload a boyband", + element=orm.FileInputElement( + max_files=1, + filetypes=[ + "png", + "jpg", + "heic", + "bmp", + ], + ), + action=actions.BACKBLAST_FILE, + optional=True, + ), + orm.InputBlock( + label="The AO", + action=actions.BACKBLAST_AO, + optional=False, + element=orm.ChannelsSelectElement(placeholder="Select the AO..."), + dispatch_action=False, + ), + orm.InputBlock( + label="Workout Date", + action=actions.BACKBLAST_DATE, + optional=False, + element=orm.DatepickerElement(placeholder="Select the date..."), + dispatch_action=False, + ), + orm.InputBlock( + label="The Q", + action=actions.BACKBLAST_Q, + optional=False, + element=orm.UsersSelectElement(placeholder="Select the Q..."), + dispatch_action=False, + ), + orm.ContextBlock( + action=actions.BACKBLAST_DUPLICATE_WARNING, + element=orm.ContextElement( + initial_value=":warning: :warning: *WARNING*: duplicate backblast detected in PAXMiner DB for this Q, " + "AO, and date; this backblast will not be saved as-is. Please modify one of these selections", + ), + ), + orm.InputBlock( + label="The CoQ(s), if any", + action=actions.BACKBLAST_COQ, + optional=True, + element=orm.MultiUsersSelectElement(placeholder="Select the CoQ(s)..."), + ), + orm.InputBlock( + label="The PAX", + action=actions.BACKBLAST_PAX, + optional=False, + element=orm.MultiUsersSelectElement(placeholder="Select the PAX..."), + hint="Don't forget you can type to search in the dropdown menu!", + ), + orm.InputBlock( + label="List untaggable PAX, separated by commas (not FNGs)", + action=actions.BACKBLAST_NONSLACK_PAX, + optional=True, + element=orm.PlainTextInputElement( + placeholder="Enter untaggable PAX...", + ), + ), + orm.InputBlock( + label="List FNGs, separated by commas", + action=actions.BACKBLAST_FNGS, + optional=True, + element=orm.PlainTextInputElement(placeholder="Enter FNGs..."), + ), + orm.InputBlock( + label="Total PAX Count", + action=actions.BACKBLAST_COUNT, + optional=True, + element=orm.PlainTextInputElement(placeholder="Total PAX count including FNGs"), + ), + orm.ContextBlock( + element=orm.ContextElement( + initial_value="If left blank, this will be calculated automatically from the fields above.", + ), + ), + orm.InputBlock( + label="The Moleskine", + action=actions.BACKBLAST_MOLESKIN, + optional=False, + element=orm.RichTextInputElement(), + hint="Due to a known Slack issue, please avoid the use of hashtags (#) in the Moleskine.", + ), + orm.DividerBlock(), + # orm.InputBlock( + # label="Choose where to post this", + # action=actions.BACKBLAST_DESTINATION, + # optional=False, + # element=orm.StaticSelectElement(placeholder="Select a destination..."), + # ), + orm.InputBlock( + label="Email Backblast (to Wordpress, etc)", + action=actions.BACKBLAST_EMAIL_SEND, + optional=False, + element=orm.RadioButtonsElement( + options=orm.as_selector_options(names=["Send Email", "Don't Send Email"], values=["yes", "no"]), + initial_value="yes", + ), + ), + ] +) + +PREBLAST_LEGACY_FORM = orm.BlockView( + blocks=[ + orm.InputBlock( + label="Title", + action=actions.PREBLAST_TITLE, + optional=False, + element=orm.PlainTextInputElement(placeholder="Enter a workout title..."), + ), + orm.InputBlock( + label="The AO", + action=actions.PREBLAST_AO, + optional=False, + element=orm.ChannelsSelectElement(placeholder="Select the AO..."), + ), + orm.InputBlock( + label="Workout Date", + action=actions.PREBLAST_DATE, + optional=False, + element=orm.DatepickerElement( + placeholder="Select the date...", + ), + ), + orm.InputBlock( + label="Workout Time", + action=actions.PREBLAST_TIME, + optional=False, + element=orm.TimepickerElement(), + ), + orm.InputBlock( + label="The Q", + action=actions.PREBLAST_Q, + optional=False, + element=orm.UsersSelectElement(placeholder="Select the Q..."), + ), + orm.InputBlock( + label="Coupons?", + action=actions.PREBLAST_COUPONS, + optional=True, + element=orm.PlainTextInputElement( # TODO: change to radio buttons or checkboxes + placeholder="Coupons or not?", + ), + ), + orm.InputBlock( + label="Moleskine", + action=actions.PREBLAST_MOLESKIN, + optional=True, + element=orm.RichTextInputElement(), + ), + orm.DividerBlock(), + orm.InputBlock( + label="Choose where to post this", + action=actions.PREBLAST_DESTINATION, + optional=False, + element=orm.StaticSelectElement(placeholder="Select a destination..."), + ), + ] +) + +ALREADY_POSTED_FORM = orm.BlockView( + blocks=[ + orm.SectionBlock( + label="This backblast has already been posted! If you want to edit it, please use the " + "edit button in the backblast post.", + action=actions.ALREADY_POSTED, + ), + orm.ContextBlock( + element=orm.ContextElement( + initial_value="If you think this is an error, please contact your region's Weasel Shaker.", + ), + ), + ] +) + +SUBMIT_FORM = orm.BlockView( + blocks=[ + orm.SectionBlock( + label=":hourglass_flowing_sand: Your form has been submitted! Saving data and posting to Slack...", + action="submit_form_message", + ), + orm.ContextBlock( + action="submit_context", + element=orm.ContextElement( + initial_value="If this form does not close automatically after a few seconds, you can close it yourself." # noqa: E501 + ), + ), + ] +) + +SUBMIT_FORM_SUCCESS = orm.BlockView( + blocks=[ + orm.SectionBlock( + label=":white_check_mark: Your form has been submitted successfully! You can close this form now.", + action="submit_form_success_message", + ), + ] +) diff --git a/apps/slackbot/utilities/slack/orm.py b/apps/slackbot/utilities/slack/orm.py new file mode 100644 index 00000000..0ee16cf6 --- /dev/null +++ b/apps/slackbot/utilities/slack/orm.py @@ -0,0 +1,978 @@ +import json +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List + +from slack_sdk.web import WebClient + +from utilities.constants import ENABLE_DEBUGGING +from utilities.helper_functions import safe_get +from utilities.slack import actions + + +@dataclass +class BaseElement: + placeholder: str = None + initial_value: str = None + + def make_placeholder_field(self): + return {"placeholder": {"type": "plain_text", "text": self.placeholder, "emoji": True}} + + def get_selected_value(): + return "Not yet implemented" + + +@dataclass +class BaseBlock: + label: str = None + action: str = None + element: BaseElement = None + + def make_label_field(self, text=None): + return {"type": "plain_text", "text": text or self.label or "", "emoji": True} + + def as_form_field(self, initial_value=None): + raise Exception("Not Implemented") + + def get_selected_value(self, input_data, action): + return "Not yet implemented" + + +@dataclass +class BaseAction: + label: str + action: str + + def make_label_field(self, text=None): + return {"type": "plain_text", "text": text or self.label, "emoji": True} + + def as_form_field(self, initial_value=None): + raise Exception("Not Implemented") + + +@dataclass +class InputBlock(BaseBlock): + optional: bool = True + element: BaseElement = None + dispatch_action: bool = False + hint: str = None + + def get_selected_value(self, input_data): + return self.element.get_selected_value(input_data, self.action) + + def as_form_field(self): + block = { + "type": "input", + "block_id": self.action, + "optional": self.optional, + "label": self.make_label_field(), + } + block.update({"element": self.element.as_form_field(action=self.action)}) + if self.dispatch_action: + block.update({"dispatch_action": True}) + if self.hint: + block.update({"hint": {"type": "plain_text", "text": self.hint, "emoji": True}}) + return block + + +@dataclass +class HeaderBlock(BaseBlock): + def get_selected_value(self, input_data): + return None + + def as_form_field(self): + block = { + "type": "header", + "text": self.make_label_field(), + } + return block + + +@dataclass +class RichTextBlock(BaseBlock): + label: Dict[str, Any] = None + + def get_selected_value(self, input_data): + # TODO: it would be nice to get the markdown text from the input data + return self.label + + def parse_rich_text(self, text: str) -> List[Dict[str, str]]: + """Generates rich text block elements from a string, and parsing lists in markdown format to bullet blocks. + + Args: + text (str): Message to parse + + Returns: + List[Dict[str, str]]: A list of rich text block elements + """ + if text == "": + return [{"type": "rich_text_section", "elements": [{"type": "text", "text": " "}]}] + msg = "" + blocks = [] + line_list = [] + text_block = {"type": "text", "text": ""} + list_block = {"type": "rich_text_list", "style": "bullet", "elements": []} + lines = text.split("\n") + matches = [line for line in lines if re.match(r"^\s*-\s", line)] + + for line in lines: + if line not in matches: + if len(line_list) > 0: + list_block_temp = list_block + list_block_temp["elements"] = [ + {"type": "rich_text_section", "elements": [{"type": "text", "text": text}]} + for text in line_list + ] + blocks.append(list_block_temp) + line_list = [] + msg += line + msg += "\n" + else: + if msg != "": + text_block_temp = text_block + text_block_temp["text"] = msg + blocks.append({"type": "rich_text_section", "elements": [text_block_temp]}) + msg = "" + + line_list.append(line[3:]) + return blocks + + def as_form_field(self): + block = self.label + if self.action: + block["block_id"] = self.action + return self.label + + +@dataclass +class SectionBlock(BaseBlock): + element: BaseElement = None + + def get_selected_value(self, input_data, **kwargs): + return self.element.get_selected_value(input_data, self.action, **kwargs) + + def make_label_field(self, text=None): + return {"type": "mrkdwn", "text": text or self.label or ""} + + def as_form_field(self): + block = {"type": "section", "text": self.make_label_field()} + if self.action: + block["block_id"] = self.action + if self.element: + block.update({"accessory": self.element.as_form_field(action=self.action)}) + return block + + +@dataclass +class ButtonElement(BaseAction): + style: str = None + value: str = None + confirm: object = None + url: str = None + + def as_form_field(self, action: str = None): + j = { + "type": "button", + "text": self.make_label_field(), + "value": self.value or self.label, + } + if action or self.action: + j["action_id"] = action or self.action + if self.style: + j["style"] = self.style + if self.confirm: + j["confirm"] = self.confirm + if self.url: + j["url"] = self.url + if self.confirm: + j["confirm"] = self.confirm.as_form_field() + return j + + +@dataclass +class SelectorOption: + name: str + value: str + description: str = None + + +def as_selector_options(names: List[str], values: List[str] = [], descriptions: List[str] = []) -> List[SelectorOption]: # noqa: B006 + if values == [] and descriptions == []: + selectors = [SelectorOption(name=x, value=x) for x in names] + elif values == []: + selectors = [ + SelectorOption(name=x, value=x, description=y[:75]) for x, y in zip(names, descriptions, strict=False) + ] + elif descriptions == []: + selectors = [SelectorOption(name=x, value=y) for x, y in zip(names, values, strict=False)] + else: + selectors = [ + SelectorOption(name=x, value=y, description=z[:75]) + for x, y, z in zip(names, values, descriptions, strict=False) + ] + return selectors + + +@dataclass +class ConfirmObject: + title: str + text: str + confirm: str + deny: str + style: str = None + + def as_form_field(self): + r = { + "title": {"type": "plain_text", "text": self.title[:100]}, + "text": {"type": "plain_text", "text": self.text[:300]}, + "confirm": {"type": "plain_text", "text": self.confirm[:30]}, + "deny": {"type": "plain_text", "text": self.deny[:30]}, + } + if self.style: + r["style"] = self.style + return r + + +@dataclass +class StaticSelectElement(BaseElement): + initial_value: str = None + options: List[SelectorOption] = None + confirm: ConfirmObject = None + action: str = None + + # def with_options(self, options: List[SelectorOption]): + # return SelectorElement(self.label, self.action, options) + + def as_form_field(self, action: str = None): + if not self.options: + self.options = as_selector_options(["Default"]) + + option_elements = [self.__make_option(o) for o in self.options] + j = {"type": "static_select", "options": option_elements, "action_id": action or self.action} + if self.placeholder: + j.update(self.make_placeholder_field()) + + initial_option = None + if self.initial_value: + initial_option = next((x for x in option_elements if x["value"] == self.initial_value), None) + if initial_option: + j["initial_option"] = initial_option + + if self.confirm: + j["confirm"] = self.confirm.as_form_field() + return j + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "selected_option", "value") + + def __make_option(self, option: SelectorOption): + j = { + "text": {"type": "plain_text", "text": option.name, "emoji": True}, + "value": option.value, + } + if option.description: + j["description"] = {"type": "plain_text", "text": option.description, "emoji": True} + return j + + +@dataclass +class MultiStaticSelectElement(BaseElement): + initial_value: List[str] = None + options: List[SelectorOption] = None + confirm: ConfirmObject = None + action: str = None + max_selected_items: int = None + + def as_form_field(self, action: str = None): + if not self.options: + self.options = as_selector_options(["Default"]) + + option_elements = [self.__make_option(o) for o in self.options] + j = {"type": "multi_static_select", "options": option_elements, "action_id": action or self.action} + if self.placeholder: + j.update(self.make_placeholder_field()) + + initial_option = None + if self.initial_value: + j["initial_options"] = [] + for value in self.initial_value: + initial_option = next((x for x in option_elements if x["value"] == value), None) + if initial_option: + j["initial_options"].append(initial_option) + + if self.confirm: + j["confirm"] = self.confirm.as_form_field() + if self.max_selected_items: + j["max_selected_items"] = self.max_selected_items + return j + + def get_selected_value(self, input_data, action): + return [o["value"] for o in (safe_get(input_data, action, action, "selected_options") or [])] + + def __make_option(self, option: SelectorOption): + j = { + "text": {"type": "plain_text", "text": option.name, "emoji": True}, + "value": option.value, + } + if option.description: + j["description"] = {"type": "plain_text", "text": option.description, "emoji": True} + return j + + +@dataclass +class MultiExternalSelectElement(BaseElement): + initial_value: List[Dict] = None + confirm: ConfirmObject = None + action: str = None + min_query_length: int = None + max_selected_items: int = None + + def as_form_field(self, action: str = None): + j = {"type": "multi_external_select", "action_id": action or self.action} + if self.placeholder: + j.update(self.make_placeholder_field()) + if self.min_query_length: + j.update({"min_query_length": self.min_query_length}) + if self.max_selected_items: + j.update({"max_selected_items": self.max_selected_items}) + if self.initial_value: + j["initial_options"] = [] + for option in self.initial_value: + j["initial_options"].append( + { + "text": {"type": "plain_text", "text": option.get("text"), "emoji": True}, + "value": option.get("value"), + } + ) + if self.confirm: + j.update({"confirm": self.confirm.as_form_field()}) + return j + + def get_selected_value(self, input_data, action): + return [o["value"] for o in (safe_get(input_data, action, action, "selected_options") or [])] + + +@dataclass +class ExternalSelectElement(BaseElement): + initial_value: Dict = None + confirm: ConfirmObject = None + action: str = None + min_query_length: int = None + + def as_form_field(self, action: str = None): + j = {"type": "external_select", "action_id": action or self.action} + if self.placeholder: + j.update(self.make_placeholder_field()) + if self.min_query_length: + j.update({"min_query_length": self.min_query_length}) + if self.initial_value: + j.update( + { + "initial_option": { + "text": {"type": "plain_text", "text": self.initial_value.get("text"), "emoji": True}, + "value": self.initial_value.get("value"), + } + } + ) + if self.confirm: + j.update({"confirm": self.confirm.as_form_field()}) + return j + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "selected_option", "value") + + +@dataclass +class RadioButtonsElement(BaseElement): + initial_value: str = None + options: List[SelectorOption] = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "selected_option", "value") + + def as_form_field(self, action: str): + if not self.options: + self.options = as_selector_options(["Default"]) + + option_elements = [self.__make_option(o) for o in self.options] + j = { + "type": "radio_buttons", + "options": option_elements, + "action_id": action, + } + + initial_option = None + if self.initial_value: + initial_option = next((x for x in option_elements if x["value"] == self.initial_value), None) + if initial_option: + j["initial_option"] = initial_option + return j + + def __make_option(self, option: SelectorOption): + return { + "text": {"type": "plain_text", "text": option.name, "emoji": True}, + "value": option.value, + } + + +@dataclass +class PlainTextInputElement(BaseElement): + initial_value: str = None + multiline: bool = False + max_length: int = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "value") + + def as_form_field(self, action: str): + j = { + "type": "plain_text_input", + "action_id": action, + "initial_value": self.initial_value or "", + } + if self.placeholder: + j.update(self.make_placeholder_field()) + if self.multiline: + j["multiline"] = True + if self.max_length: + j["max_length"] = self.max_length + return j + + +@dataclass +class EmailInputElement(BaseElement): + initial_value: str = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "value") + + def as_form_field(self, action: str): + j = { + "type": "email_text_input", + "action_id": action, + } + if self.initial_value: + j["initial_value"] = self.initial_value + if self.placeholder: + j.update(self.make_placeholder_field()) + return j + + +@dataclass +class URLInputElement(BaseElement): + initial_value: str = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "value") + + def as_form_field(self, action: str): + j = { + "type": "url_text_input", + "action_id": action, + } + if self.initial_value: + j["initial_value"] = self.initial_value + if self.placeholder: + j.update(self.make_placeholder_field()) + return j + + +@dataclass +class RichTextInputElement(BaseElement): + initial_value: Dict[str, Any] = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "rich_text_value") + + def as_form_field(self, action: str): + j = { + "type": "rich_text_input", + "action_id": action, + } + if self.initial_value: + j["initial_value"] = self.initial_value + if self.placeholder: + j.update(self.make_placeholder_field()) + return j + + +@dataclass +class NumberInputElement(BaseElement): + initial_value: float | str = None + min_value: float = None + max_value: float = None + is_decimal_allowed: bool = True + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "value") + + def as_form_field(self, action: str): + j = { + "type": "number_input", + "action_id": action, + "is_decimal_allowed": self.is_decimal_allowed, + } + if self.initial_value is not None: + if isinstance(self.initial_value, str) and self.is_decimal_allowed: + try: + self.initial_value = float(self.initial_value) + except ValueError: + self.initial_value = 0.0 + j["initial_value"] = str(round(self.initial_value, 4)) + elif isinstance(self.initial_value, str) and not self.is_decimal_allowed: + try: + self.initial_value = int(self.initial_value) + except ValueError: + self.initial_value = 0 + j["initial_value"] = str(self.initial_value) + elif self.is_decimal_allowed: + j["initial_value"] = str(round(self.initial_value, 4)) + else: + j["initial_value"] = str(int(self.initial_value)) + if self.min_value: + j["min_value"] = str(self.min_value) + if self.max_value: + j["max_value"] = str(self.max_value) + return j + + +@dataclass +class ChannelsSelectElement(BaseElement): + initial_value: str = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "selected_channel") + + def as_form_field(self, action: str): + j = { + "type": "channels_select", + "action_id": action, + } + if self.placeholder: + j.update(self.make_placeholder_field()) + if self.initial_value: + j["initial_channel"] = self.initial_value + return j + + +@dataclass +class MultiChannelsSelectElement(BaseElement): + initial_value: List[str] = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "selected_channels") + + def as_form_field(self, action: str): + j = { + "type": "multi_channels_select", + "action_id": action, + } + if self.placeholder: + j.update(self.make_placeholder_field()) + if self.initial_value: + j["initial_channels"] = self.initial_value + return j + + +@dataclass +class ConversationsSelectElement(BaseElement): + initial_value: str = None + filter: List[str] = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "selected_conversation") + + def as_form_field(self, action: str): + j = { + "type": "conversations_select", + "action_id": action, + } + if self.placeholder: + j.update(self.make_placeholder_field()) + if self.initial_value: + j["initial_conversation"] = self.initial_value + if self.filter: + j["filter"] = {"include": self.filter} + return j + + +@dataclass +class DatepickerElement(BaseElement): + initial_value: str = None + action: str = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "selected_date") + + def as_form_field(self, action: str = None): + j = { + "type": "datepicker", + "action_id": action or self.action, + } + if self.placeholder: + j.update(self.make_placeholder_field()) + if self.initial_value: + j["initial_date"] = self.initial_value + return j + + +@dataclass +class TimepickerElement(BaseElement): + initial_value: str = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "selected_time") + + def as_form_field(self, action: str): + j = { + "type": "timepicker", + "action_id": action, + } + if self.placeholder: + j.update(self.make_placeholder_field()) + if self.initial_value: + j["initial_time"] = self.initial_value + return j + + +@dataclass +class UsersSelectElement(BaseElement): + initial_value: str = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "selected_user") + + def as_form_field(self, action: str): + j = { + "type": "users_select", + "action_id": action, + } + if self.placeholder: + j.update(self.make_placeholder_field()) + if self.initial_value: + j["initial_user"] = self.initial_value + return j + + +@dataclass +class MultiUsersSelectElement(BaseElement): + initial_value: List[str] = None + max_selected_items: int = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "selected_users") + + def as_form_field(self, action: str): + j = { + "type": "multi_users_select", + "action_id": action, + } + if self.placeholder: + j.update(self.make_placeholder_field()) + if self.initial_value: + j["initial_users"] = self.initial_value + if self.max_selected_items: + j["max_selected_items"] = self.max_selected_items + return j + + +@dataclass +class FileInputElement(BaseElement): + max_files: int = None + filetypes: List[str] = None + + def get_selected_value(self, input_data, action): + return safe_get(input_data, action, action, "files") + + def as_form_field(self, action: str): + j = { + "type": "file_input", + "action_id": action, + } + if self.max_files: + j["max_files"] = self.max_files + if self.filetypes: + j["filetypes"] = self.filetypes + return j + + +@dataclass +class CheckboxInputElement(BaseElement): + initial_value: List[str] = None + options: List[SelectorOption] = None + action: str = None + + def get_selected_value(self, input_data, action): + return [o["value"] for o in (safe_get(input_data, action, action, "selected_options") or [])] + + def as_form_field(self, action: str = None): + if not self.options: + self.options = as_selector_options(["Default"]) + + option_elements = [self.__make_option(o) for o in self.options] + j = { + "type": "checkboxes", + "options": option_elements, + "action_id": action or self.action, + } + + initial_options = [] + if self.initial_value: + initial_options = [x for x in option_elements if x["value"] in self.initial_value] + if initial_options: + j["initial_options"] = initial_options + return j + + def __make_option(self, option: SelectorOption): + option_dict = { + "text": {"type": "plain_text", "text": option.name, "emoji": True}, + "value": option.value, + } + if option.description: + option_dict["description"] = {"type": "plain_text", "text": option.description, "emoji": True} + return option_dict + + +@dataclass +class OverflowElement(BaseElement): + options: List[SelectorOption] = None + action: str = None + confirm: ConfirmObject = None + + def as_form_field(self, action: str = None): + if not self.options: + self.options = as_selector_options(["Default"]) + + option_elements = [self.__make_option(o) for o in self.options] + j = { + "type": "overflow", + "options": option_elements, + "action_id": action or self.action, + } + if self.confirm: + j["confirm"] = self.confirm.as_form_field() + + return j + + def __make_option(self, option: SelectorOption): + return { + "text": {"type": "plain_text", "text": option.name, "emoji": True}, + "value": option.value, + } + + +@dataclass +class ContextBlock(BaseBlock): + element: BaseElement = None + initial_value: str = "" + + def get_selected_value(self, input_data, action): + for block in input_data: + if block["block_id"] == action: + return block["elements"][0]["text"] + return None + + def as_form_field(self): + j = {"type": "context"} + j.update({"elements": [self.element.as_form_field()]}) + if self.action: + j["block_id"] = self.action + return j + + +@dataclass +class ImageBlock(BaseBlock): + image_url: str = None + alt_text: str = None + slack_file_id: str = None + + def as_form_field(self, action: str = None): + j = { + "type": "image", + # "image_url": self.image_url, + "alt_text": self.alt_text, + } + if self.image_url: + j["image_url"] = self.image_url + elif self.slack_file_id: + j["slack_file"] = {"id": self.slack_file_id} + if self.action: + j["block_id"] = self.action + if self.label: + j["title"] = self.make_label_field() + return j + + +@dataclass +class ContextElement(BaseElement): + initial_value: str = None + + def as_form_field(self): + j = { + "type": "mrkdwn", + "text": self.initial_value, + } + return j + + +@dataclass +class DividerBlock(BaseBlock): + def as_form_field(self): + return {"type": "divider"} + + +@dataclass +class ActionsBlock(BaseBlock): + elements: List[BaseAction] = field(default_factory=list) + + def as_form_field(self): + j = { + "type": "actions", + "elements": [e.as_form_field() for e in self.elements], + } + if self.action: + j["block_id"] = self.action + return j + + +@dataclass +class BlockView: + blocks: List[BaseBlock] + + def delete_block(self, action: str): + self.blocks = [b for b in self.blocks if b.action != action] + + def add_block(self, block: BaseBlock): + self.blocks.append(block) + + def set_initial_values(self, values: dict): + for block in self.blocks: + if block.action in values and isinstance(block, InputBlock): + block.element.initial_value = values[block.action] + elif block.action in values and isinstance(block, SectionBlock): + block.label = values[block.action] + elif block.action in values and isinstance(block, ImageBlock): + block.image_url = values[block.action] + + def set_options(self, options: Dict[str, List[SelectorOption]]): + for block in self.blocks: + if block.action in options: + option_list = options[block.action] + for option in option_list: + option.name = option.name[:75] + if option.description: + option.description = option.description[:75] + block.element.options = option_list + + def as_form_field(self) -> List[dict]: + return [b.as_form_field() for b in self.blocks] + + def get_selected_values(self, body) -> dict: + values = body["view"]["state"]["values"] + view_blocks = body["view"]["blocks"] + + selected_values = {} + for block in self.blocks: + if isinstance(block, InputBlock): + selected_values[block.action] = block.get_selected_value(values) + elif isinstance(block, ContextBlock) and block.action: + selected_values[block.action] = block.get_selected_value(view_blocks, block.action) + elif isinstance(block, ActionsBlock): # TODO: fix this, doesn't really work yet + for element in block.elements: + selected_values[element.action] = block.get_selected_value(values, element.action) + + return selected_values + + def post_modal( + self, + client: WebClient, + trigger_id: str, + title_text: str, + callback_id: str, + submit_button_text: str = "Submit", + parent_metadata: dict = None, + close_button_text: str = "Close", + notify_on_close: bool = False, + new_or_add: str = "new", + ) -> dict: + blocks = self.as_form_field() + + view = { + "type": "modal", + "callback_id": callback_id, + "title": {"type": "plain_text", "text": title_text}, + "close": {"type": "plain_text", "text": close_button_text}, + "notify_on_close": notify_on_close, + "blocks": blocks, + } + if parent_metadata: + view["private_metadata"] = json.dumps(parent_metadata) + + if submit_button_text != "None": # TODO: would prefer this to use None instead of "None" + view["submit"] = {"type": "plain_text", "text": submit_button_text} + + if ENABLE_DEBUGGING: + view["external_id"] = actions.DEBUG_FORM_EXTERNAL_ID + res = client.views_update(external_id=actions.DEBUG_FORM_EXTERNAL_ID, view=view) + elif new_or_add == "new": + res = client.views_open(trigger_id=trigger_id, view=view) + elif new_or_add == "add": + res = client.views_push(trigger_id=trigger_id, view=view) + return res + + def update_modal( + self, + client: WebClient, + view_id: str, + title_text: str, + callback_id: str, + submit_button_text: str = "Submit", + parent_metadata: dict = None, + close_button_text: str = "Close", + notify_on_close: bool = False, + ) -> dict: + blocks = self.as_form_field() + + view = { + "type": "modal", + "callback_id": callback_id, + "title": {"type": "plain_text", "text": title_text}, + "close": {"type": "plain_text", "text": close_button_text}, + "notify_on_close": notify_on_close, + "blocks": blocks, + } + if parent_metadata: + view["private_metadata"] = json.dumps(parent_metadata) + if submit_button_text != "None": + view["submit"] = {"type": "plain_text", "text": submit_button_text} + + try: + if ENABLE_DEBUGGING: + view["external_id"] = actions.DEBUG_FORM_EXTERNAL_ID + res = client.views_update(external_id=actions.DEBUG_FORM_EXTERNAL_ID, view=view) + else: + res = client.views_update(view_id=view_id, view=view) + except Exception as e: + # TODO: handle "not found" errors; post new instead of update? + print(f"Failed to update modal: {e}") + res = None + + return res + + +def parse_welcome_template(template: str, user_id: str) -> List[BaseBlock]: + blocks = [] + + # 1. Insert user name into template + msg = template.replace("{user}", f"<@{user_id}>") + + # 2. Insert divider blocks + msg_split = msg.split("/divider") + blocks.append(SectionBlock(label=msg_split[0])) + + if len(msg_split) > 1: + for m in msg_split[1:]: + blocks.append(DividerBlock()) + blocks.append(SectionBlock(label=m)) + + return blocks diff --git a/apps/slackbot/utilities/slack/sdk_orm.py b/apps/slackbot/utilities/slack/sdk_orm.py new file mode 100644 index 00000000..fee20d1f --- /dev/null +++ b/apps/slackbot/utilities/slack/sdk_orm.py @@ -0,0 +1,253 @@ +import json +from typing import Any, Dict, List + +from slack_sdk.models.blocks import Block, ImageBlock, InputBlock, SectionBlock +from slack_sdk.models.blocks.basic_components import Option +from slack_sdk.models.views import View + +# slack_sdk.models.composition_objects.Option +from utilities.constants import ENABLE_DEBUGGING +from utilities.helper_functions import safe_get +from utilities.slack import actions + + +def as_selector_options(names: List[str], values: List[str] = None, descriptions: List[str] = None) -> List[Option]: + """Helper to create a list of Option objects from a list of names and values.""" + options = [] + for i, name in enumerate(names): + value = values[i] if values else name + description = descriptions[i] if descriptions else None + options.append(Option(text=name, value=value, description=description)) + if not options: + options.append(Option(text="No options available", value="none")) + return options + + +class SdkBlockView: + """ + A wrapper for building Slack views using slack_sdk.models objects. + This provides similar functionality to the custom BlockView but uses the + official slack_sdk components as the base. + """ + + def __init__(self, blocks: List[Block]): + self.blocks = blocks + + def delete_block(self, block_id: str): + """Removes a block from the view by its block_id.""" + self.blocks = [b for b in self.blocks if getattr(b, "block_id", None) != block_id] + + def add_block(self, block: Block): + """Adds a block to the view.""" + self.blocks.append(block) + + def get_block(self, block_id: str) -> Block | None: + """Finds a block in the view by its block_id.""" + for block in self.blocks: + if getattr(block, "block_id", None) == block_id: + return block + return None + + def set_initial_values(self, values: dict): + """ + Sets initial values for elements within the blocks. + NOTE: This has limited support and works best with InputBlocks. + """ + for block in self.blocks: + if isinstance(block, InputBlock) and block.block_id in values: + if hasattr(block.element, "initial_value"): + if block.element.type == "number_input": + if isinstance(values[block.block_id], str): + try: + values[block.block_id] = float(values[block.block_id]) + except ValueError: + values[block.block_id] = 0.0 + if block.element.is_decimal_allowed: + values[block.block_id] = round(values[block.block_id], 4) + else: + values[block.block_id] = int(values[block.block_id]) + block.element.initial_value = str(values[block.block_id]) + elif block.element.type in ["multi_static_select", "checkboxes"]: + block.element.initial_options = [] + for value in values[block.block_id]: + selected_option = next((x for x in block.element.options if x.value == value), None) + if selected_option: + block.element.initial_options.append(selected_option) + elif block.element.type in ["static_select", "radio_buttons"]: + selected_option = next( + (x for x in block.element.options if x.value == values[block.block_id]), None + ) + if selected_option: + block.element.initial_option = selected_option + elif block.element.type == "external_select": + block.element.initial_option = { + "text": {"type": "plain_text", "text": values[block.block_id].get("text", "")}, + "value": values[block.block_id].get("value", ""), + } + elif block.element.type == "multi_external_select": + for value in values[block.block_id]: + block.element.initial_options.append( + { + "text": {"type": "plain_text", "text": value.get("text", "")}, + "value": value.get("value", ""), + } + ) + elif block.element.type == "channels_select": + block.element.initial_channel = values[block.block_id] + elif block.element.type == "multi_channels_select": + block.element.initial_channels = values[block.block_id] + elif block.element.type == "conversations_select": + block.element.initial_conversation = values[block.block_id] + elif block.element.type == "multi_conversations_select": + block.element.initial_conversations = values[block.block_id] + elif block.element.type == "datepicker": + block.element.initial_date = values[block.block_id] + elif block.element.type == "timepicker": + block.element.initial_time = values[block.block_id] + elif block.element.type == "users_select": + block.element.initial_user = values[block.block_id] + elif block.element.type == "multi_users_select": + block.element.initial_users = values[block.block_id] + # TODO: Add support for context block + elif isinstance(block, SectionBlock) and block.block_id in values: + if hasattr(block.text, "text"): + block.text.text = values[block.block_id] + elif isinstance(block, ImageBlock) and block.block_id in values: + block.image_url = values[block.block_id] + + def set_options(self, options: Dict[str, List[Option]]): + """ + Sets options for select elements within the blocks. + """ + for block in self.blocks: + if isinstance(block, InputBlock) and block.block_id in options: + if hasattr(block.element, "options"): + option_list = options[block.block_id] + for option in option_list: + if option.label is not None: + option.label = option.label[:75] + if option.description: + option.description = option.description[:75] + block.element.options = option_list + + def to_dict_list(self) -> List[dict]: + """Serializes all blocks to a list of dictionaries.""" + return [b.to_dict() for b in self.blocks] + + def get_selected_values(self, body: dict) -> dict: + """ + Parses the selected values from a view_submission payload. + This is based on the structure of `view.state.values`. + """ + values = safe_get(body, "view", "state", "values") + if not values: + return {} + + selected_values = {} + for block_id, block_values in values.items(): + for _, state in block_values.items(): + element_type = state.get("type") + value = None + if element_type in [ + "plain_text_input", + "email_text_input", + "url_text_input", + "number_input", + "datepicker", + "timepicker", + ]: + value = state.get("value") + elif element_type in ["users_select", "conversations_select", "channels_select"]: + value = ( + state.get("selected_user") + or state.get("selected_conversation") + or state.get("selected_channel") + ) + elif element_type in ["multi_users_select", "multi_conversations_select", "multi_channels_select"]: + value = ( + state.get("selected_users") + or state.get("selected_conversations") + or state.get("selected_channels") + ) + elif element_type in ["static_select", "external_select", "radio_buttons"]: + if state.get("selected_option"): + value = state.get("selected_option", {}).get("value") + elif element_type in ["multi_static_select", "multi_external_select", "checkboxes"]: + value = [o.get("value") for o in state.get("selected_options", [])] + elif element_type == "rich_text_input": + value = state.get("rich_text_value") + elif element_type == "file_input": + value = state.get("files") + + selected_values[block_id] = value + return selected_values + + def post_modal( + self, + client: Any, + trigger_id: str, + title_text: str, + callback_id: str, + submit_button_text: str = "Submit", + parent_metadata: dict = None, + close_button_text: str = "Close", + notify_on_close: bool = False, + new_or_add: str = "new", + ) -> dict: + """Posts the view as a new modal.""" + view = View( + type="modal", + callback_id=callback_id, + title=title_text, + close=close_button_text, + notify_on_close=notify_on_close, + blocks=self.blocks, + submit=submit_button_text if submit_button_text and submit_button_text.lower() != "none" else None, + ) + if parent_metadata: + view.private_metadata = json.dumps(parent_metadata) + + if ENABLE_DEBUGGING: + view.external_id = actions.DEBUG_FORM_EXTERNAL_ID + return client.views_update(external_id=actions.DEBUG_FORM_EXTERNAL_ID, view=view.to_dict()) + elif new_or_add == "new": + return client.views_open(trigger_id=trigger_id, view=view.to_dict()) + elif new_or_add == "add": + return client.views_push(trigger_id=trigger_id, view=view.to_dict()) + + def update_modal( + self, + client: Any, + view_id: str, + title_text: str, + callback_id: str, + submit_button_text: str = "Submit", + parent_metadata: dict = None, + close_button_text: str = "Close", + notify_on_close: bool = False, + external_id: str = None, + ): + """Updates an existing modal view.""" + view = View( + type="modal", + callback_id=callback_id, + title=title_text, + close=close_button_text, + notify_on_close=notify_on_close, + blocks=self.blocks, + submit=submit_button_text if submit_button_text and submit_button_text.lower() != "none" else None, + ) + if parent_metadata: + view.private_metadata = json.dumps(parent_metadata) + + try: + if ENABLE_DEBUGGING: + view.external_id = actions.DEBUG_FORM_EXTERNAL_ID + return client.views_update(external_id=actions.DEBUG_FORM_EXTERNAL_ID, view=view.to_dict()) + elif external_id: + return client.views_update(external_id=external_id, view=view.to_dict()) + else: + return client.views_update(view_id=view_id, view=view.to_dict()) + except Exception as e: + # TODO: handle "not found" errors; post new instead of update? + print(f"Failed to update modal: {e}") diff --git a/commitlint.config.mjs b/commitlint.config.mjs index 70df17a2..ad9e755f 100644 --- a/commitlint.config.mjs +++ b/commitlint.config.mjs @@ -1,55 +1,56 @@ /** @type {import('@commitlint/types').UserConfig} */ export default { - extends: ['@commitlint/config-conventional'], + extends: ["@commitlint/config-conventional"], rules: { - 'scope-enum': [ + "scope-enum": [ 2, - 'always', + "always", [ // apps - 'admin', - 'homepage', - 'map', - 'me', + "admin", + "homepage", + "map", + "me", + "slackbot", // apps & packages (exist in both apps/ and packages/) - 'api', - 'auth', + "api", + "auth", // packages - 'db', - 'env', - 'mail', - 'shared', - 'sso', - 'storage', - 'ui', - 'validators', + "db", + "env", + "mail", + "shared", + "sso", + "storage", + "ui", + "validators", // tooling - 'eslint', - 'prettier', - 'tsconfig', - 'scripts', - 'github', - 'tailwind', + "eslint", + "prettier", + "tsconfig", + "scripts", + "github", + "tailwind", // cross-cutting - 'deps', - 'ci', - 'repo', - 'release', + "deps", + "ci", + "repo", + "release", // release-please uses the target branch as the scope (e.g. "chore(dev): release me 1.2.0") - 'dev', + "dev", ], ], - 'scope-empty': [2, 'never'], + "scope-empty": [2, "never"], }, prompt: { settings: { enableMultipleScopes: true, - scopeEnumSeparator: ',', + scopeEnumSeparator: ",", }, }, }; diff --git a/docs/LOCAL_DEV_DOCKER.md b/docs/LOCAL_DEV_DOCKER.md index eb01bcf9..02a6c76f 100644 --- a/docs/LOCAL_DEV_DOCKER.md +++ b/docs/LOCAL_DEV_DOCKER.md @@ -161,7 +161,7 @@ pnpm local:setup This script does everything automatically: - Copies each directory's `.env.local.example` → `.env` (skips any that already exist): - `apps/api`, `apps/auth`, `apps/map`, `apps/me`, `apps/admin`, and `packages/env` + `apps/api`, `apps/auth`, `apps/map`, `apps/me`, `apps/admin`, `apps/slackbot`, and `packages/env` - Starts the four Docker containers - Waits for Postgres to be ready - Creates the `f3-public-images` bucket in the GCS emulator @@ -216,6 +216,7 @@ pnpm dev | Me | http://localhost:3003 | | Auth | http://localhost:3004 | | Homepage | http://localhost:3005 | +| Slackbot | http://localhost:3006 | --- @@ -255,14 +256,15 @@ The Docker containers save their data in named volumes (`postgres_data`, `gcs_da Each app and shared package has its own `.env` file, copied from a `.env.local.example` template during `pnpm local:setup`. All template values work out-of-the-box with Docker — you don't need to edit anything to get started. -| Directory | Purpose | -| ------------------- | ------------------------------------------------------------------ | -| `apps/api/.env` | API app (Next.js on port 3001) | -| `apps/auth/.env` | Auth app (Next.js on port 3004) | -| `apps/map/.env` | Map app (Next.js on port 3000) | -| `apps/admin/.env` | Admin app (Next.js on port 3002) | -| `apps/me/.env` | Me app (Next.js on port 3003) | -| `packages/env/.env` | Shared backend env root (used by `packages/db` and `packages/api`) | +| Directory | Purpose | +| -------------------- | ------------------------------------------------------------------ | +| `apps/api/.env` | API app (Next.js on port 3001) | +| `apps/auth/.env` | Auth app (Next.js on port 3004) | +| `apps/map/.env` | Map app (Next.js on port 3000) | +| `apps/admin/.env` | Admin app (Next.js on port 3002) | +| `apps/me/.env` | Me app (Next.js on port 3003) | +| `apps/slackbot/.env` | Slackbot app (Python Socket Mode app on port 3006) | +| `packages/env/.env` | Shared backend env root (used by `packages/db` and `packages/api`) | Here's what each variable means: diff --git a/package.json b/package.json index fe89742a..061b636e 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "lint:unused": "knip", "lint": "turbo run lint --continue -- --cache --cache-location node_modules/.cache/.eslintcache; pnpm run lint:ws", "local:setup": "bash scripts/local-setup.sh", + "postinstall": "uv sync", "release": "pnpm -F release-it release", "reset-test-db": "turbo run reset-test-db", "test": "turbo test", diff --git a/packages/db-python/.env.example b/packages/db-python/.env.example new file mode 100644 index 00000000..d3443145 --- /dev/null +++ b/packages/db-python/.env.example @@ -0,0 +1,6 @@ +export DATABASE_HOST=localhost +export DATABASE_USER= +export DATABASE_PASSWORD= +export DATABASE_SCHEMA= +export DATABASE_PORT=5432 +export USE_GCP=False \ No newline at end of file diff --git a/packages/db-python/.gitignore b/packages/db-python/.gitignore new file mode 100644 index 00000000..bd5a7af8 --- /dev/null +++ b/packages/db-python/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +.env +_build +dist +f3_data_models.egg-info \ No newline at end of file diff --git a/packages/db-python/README.md b/packages/db-python/README.md new file mode 100644 index 00000000..fc7c1a92 --- /dev/null +++ b/packages/db-python/README.md @@ -0,0 +1,72 @@ +# Overview + +This repository defines the F3 data structure, used by the F3 Slack Bot, Maps, etc. The projected uses SQLAlchemy to define the tables / models. + +# Running Locally + +To load the data structure in your database: + +1. Set up a local db, update `.env.example` and save as `.env` +2. Clone the repo, use Poetry to install dependencies: + +```sh +poetry env use 3.12 +poetry install +``` + +3. Run the alembic migration: + +```sh +source .env && poetry run alembic upgrade head +``` + +# Optional BigQuery Sessions + +The default database session path is PostgreSQL. To explicitly request a BigQuery session, pass the optional `backend` argument: + +```python +from f3_data_models.utils import get_session + +postgres_session = get_session() +bigquery_session = get_session(backend="bigquery") +``` + +`session_scope(...)` and `DbManager` methods also accept the same optional `backend` argument. + +BigQuery mode requires these environment variables: + +- `BIGQUERY_PROJECT` +- `BIGQUERY_DATASET` + +Authentication should be provided through Google Application Default Credentials in the runtime environment. + +# Entity Overview + +```mermaid +--- +config: + look: handDrawn + theme: dark +--- + +erDiagram + USERS ||--|{ ATTENDANCE : have + ATTENDANCE }|--|| EVENT_INSTANCES: at + ATTENDANCE }|..|{ ATTENDANCE_TYPES : "are of type(s)" + EVENT_INSTANCES }|..|| EVENTS : "part of series" + EVENT_INSTANCES }|..|{ EVENT_TYPES : "with type(s)" + EVENTS }|..|{ EVENT_TYPES : "with type(s)" + EVENT_INSTANCES }|--|| ORGS : "belong to" + EVENT_INSTANCES }|..|| LOCATIONS : "at" + EVENTS }|--|| ORGS : "belong to" + EVENTS }|..|| LOCATIONS : "at" + SLACK_SPACES ||..|| ORGS : "are connected to" + USERS ||..|{ SLACK_USERS : "have one or more" + SLACK_USERS }|--|| SLACK_SPACES : "belong to" + USERS }|..|{ ACHIEVEMENTS : "earn" + USERS }|..|{ ROLES : "have" + ROLES ||..|{ PERMISSIONS : "have" + ROLES }|..|{ ORGS : "in" + USERS }|..|{ POSITIONS : "hold" + POSITIONS }|..|{ ORGS : "in" +``` diff --git a/packages/db-python/f3_data_models/__init__.py b/packages/db-python/f3_data_models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/db-python/f3_data_models/models.py b/packages/db-python/f3_data_models/models.py new file mode 100644 index 00000000..bff35c3d --- /dev/null +++ b/packages/db-python/f3_data_models/models.py @@ -0,0 +1,1709 @@ +import enum +from datetime import date, datetime, time +from typing import Any, Dict, List, Optional + +from citext import CIText +from sqlalchemy import ( + ARRAY, + JSON, + REAL, + TEXT, + TIME, + UUID, + VARCHAR, + BigInteger, + Boolean, + DateTime, + Enum, + Float, + ForeignKey, + Index, + Integer, + UniqueConstraint, + Uuid, + func, + inspect, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + mapped_column, + relationship, +) +from sqlalchemy.orm.attributes import InstrumentedAttribute +from typing_extensions import Annotated + +# Custom Annotations +time_notz = Annotated[time, TIME(timezone=False)] +time_with_tz = Annotated[time, TIME(timezone=True)] +ts_notz = Annotated[datetime, DateTime(timezone=False)] +text = Annotated[str, TEXT] +intpk = Annotated[int, mapped_column(Integer, primary_key=True, autoincrement=True)] +dt_create = Annotated[datetime, mapped_column(DateTime, server_default=func.timezone("utc", func.now()))] +dt_update = Annotated[ + datetime, + mapped_column( + DateTime, + server_default=func.timezone("utc", func.now()), + server_onupdate=func.timezone("utc", func.now()), + ), +] + + +class Codex_Submission_Status(enum.Enum): + """ + Enum representing the status of a codex submission. + + Attributes: + pending + approved + rejected + """ + + pending = 1 + approved = 2 + rejected = 3 + + +class User_Status(enum.Enum): + """ + Enum representing the status of a user. + + Attributes: + active + inactive + deleted + """ + + active = 1 + inactive = 2 + deleted = 3 + + +class Region_Role(enum.Enum): + """ + Enum representing the roles within a region. + + Attributes: + user + editor + admin + """ + + user = 1 + editor = 2 + admin = 3 + + +class User_Role(enum.Enum): + """ + Enum representing the roles of a user. + + Attributes: + user + editor + admin + """ + + user = 1 + editor = 2 + admin = 3 + + +class Update_Request_Status(enum.Enum): + """ + Enum representing the status of an update request. + + Attributes: + pending + approved + rejected + """ + + pending = 1 + approved = 2 + rejected = 3 + + +class Day_Of_Week(enum.Enum): + """ + Enum representing the days of the week. + """ + + monday = 0 + tuesday = 1 + wednesday = 2 + thursday = 3 + friday = 4 + saturday = 5 + sunday = 6 + + +class Event_Cadence(enum.Enum): + """ + Enum representing the cadence of an event. + + Attributes: + weekly + monthly + """ + + weekly = 1 + monthly = 2 + + +class Achievement_Cadence(enum.Enum): + """ + Enum representing the cadence of an achievement. + + Attributes: + weekly + monthly + quarterly + yearly + lifetime + """ + + weekly = 1 + monthly = 2 + quarterly = 3 + yearly = 4 + lifetime = 5 + + +class Org_Type(enum.Enum): + """ + Enum representing the type of organization. + + Attributes: + ao + region + area + sector + nation + """ + + ao = 1 + region = 2 + area = 3 + sector = 4 + nation = 5 + + +class Event_Category(enum.Enum): + """ + Enum representing the category of an event. + + Attributes: + first_f + second_f + third_f + """ + + first_f = 1 + second_f = 2 + third_f = 3 + + +class Request_Type(enum.Enum): + """ + Enum representing the type of request. + + Attributes: + create_location + create_event + edit + delete_event + """ + + create_location = 1 + create_event = 2 + edit = 3 + delete_event = 4 + + +class Series_Exception(enum.Enum): + """ + Enum representing exceptions to an event series. + + Attributes: + none + skip + reschedule + """ + + closed = 1 + different_time = 2 + miscellaneous = 3 + + +class Base(DeclarativeBase): + """ + Base class for all models, providing common methods. + + Methods: + get_id: Get the primary key of the model. + get: Get the value of a specified attribute. + to_json: Convert the model instance to a JSON-serializable dictionary. + __repr__: Get a string representation of the model instance. + _update: Update the model instance with the provided fields. + """ + + type_annotation_map = { + Dict[str, Any]: JSON, + } + + def get_id(self): + """ + Get the primary key of the model. + + Returns: + int: The primary key of the model. + """ + return self.id + + def get(self, attr): + """ + Get the value of a specified attribute. + + Args: + attr (str): The name of the attribute. + + Returns: + Any: The value of the attribute if it exists, otherwise None. + """ + if attr in [c.key for c in self.__table__.columns]: + return getattr(self, attr) + return None + + def to_json(self): + """ + Convert the model instance to a JSON-serializable dictionary. + + Returns: + dict: A dictionary representation of the model instance. + """ + return {c.key: self.get(c.key) for c in self.__table__.columns if c.key not in ["created", "updated"]} + + def to_update_dict(self) -> Dict[InstrumentedAttribute, Any]: + update_dict = {} + mapper = inspect(self).mapper + + # Add simple attributes + for attr in mapper.column_attrs: + if attr.key not in ["created", "updated", "id"]: + update_dict[attr] = getattr(self, attr.key) + + # Add relationships + for rel in mapper.relationships: + related_value = getattr(self, rel.key) + if related_value is not None: + if rel.uselist: + update_dict[rel] = list(related_value) + print(rel, update_dict[rel]) + else: + update_dict[rel] = related_value + return update_dict + + def __repr__(self): + """ + Get a string representation of the model instance. + + Returns: + str: A string representation of the model instance. + """ + return str(self.to_json()) + + def _update(self, fields): + """ + Update the model instance with the provided fields. + + Args: + fields (dict): A dictionary of fields to update. + + Returns: + Base: The updated model instance. + """ + for k, v in fields.items(): + attr_name = str(k).split(".")[-1] + setattr(self, attr_name, v) + return self + + +class SlackSpace(Base): + """ + Model representing a Slack workspace. + + Attributes: + id (int): Primary Key of the model. + team_id (str): The Slack-internal unique identifier for the Slack team. + workspace_name (Optional[str]): The name of the Slack workspace. + bot_token (Optional[str]): The bot token for the Slack workspace. + settings (Optional[Dict[str, Any]]): Slack Bot settings for the Slack workspace. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ + + __tablename__ = "slack_spaces" + + id: Mapped[intpk] + team_id: Mapped[str] = mapped_column(VARCHAR, unique=True) + workspace_name: Mapped[Optional[str]] + bot_token: Mapped[Optional[str]] + settings: Mapped[Optional[Dict[str, Any]]] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class Role(Base): + """ + Model representing a role. A role is a set of permissions that can be assigned to users. + + Attributes: + id (int): Primary Key of the model. + name (str): The unique name of the role. + description (Optional[text]): A description of the role. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ + + __tablename__ = "roles" + + id: Mapped[intpk] + name: Mapped[str] = mapped_column(VARCHAR, unique=True) + description: Mapped[Optional[text]] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class Permission(Base): + """ + Model representing a permission. + + Attributes: + id (int): Primary Key of the model. + name (str): The name of the permission. + description (Optional[text]): A description of the permission. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ + + __tablename__ = "permissions" + + id: Mapped[intpk] + name: Mapped[str] + description: Mapped[Optional[text]] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class Role_x_Permission(Base): + """ + Model representing the assignment of permissions to roles. + + Attributes: + role_id (int): The ID of the associated role. + permission_id (int): The ID of the associated permission. + """ + + __tablename__ = "roles_x_permissions" + + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), primary_key=True) + permission_id: Mapped[int] = mapped_column(ForeignKey("permissions.id"), primary_key=True) + + +class Role_x_User_x_Org(Base): + """ + Model representing the assignment of roles, users, and organizations. + + Attributes: + role_id (int): The ID of the associated role. + user_id (int): The ID of the associated user. + org_id (int): The ID of the associated organization. + """ + + __tablename__ = "roles_x_users_x_org" + + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"), primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) + org_id: Mapped[int] = mapped_column(ForeignKey("orgs.id"), primary_key=True) + + +class Org(Base): + """ + Model representing an organization. The same model is used for all levels of organization (AOs, Regions, etc.). + + Attributes: + id (int): Primary Key of the model. + parent_id (Optional[int]): The ID of the parent organization. + org_type (Org_Type): The type of the organization. + default_location_id (Optional[int]): The ID of the default location. + name (str): The name of the organization. + description (Optional[text]): A description of the organization. + is_active (bool): Whether the organization is active. + logo_url (Optional[str]): The URL of the organization's logo. + website (Optional[str]): The organization's website. + email (Optional[str]): The organization's email. + twitter (Optional[str]): The organization's Twitter handle. + facebook (Optional[str]): The organization's Facebook page. + instagram (Optional[str]): The organization's Instagram handle. + last_annual_review (Optional[date]): The date of the last annual review. + meta (Optional[Dict[str, Any]]): Additional metadata for the organization. + ao_count (int): The number of AOs associated with the organization. Defaults to 0, will be updated by triggers. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + + locations (Optional[List[Location]]): The locations associated with the organization. Probably only relevant for regions. + event_types (Optional[List[EventType]]): The event types associated with the organization. Used to control which event types are available for selection at the region level. + event_tags (Optional[List[EventTag]]): The event tags associated with the organization. Used to control which event tags are available for selection at the region level. + achievements (Optional[List[Achievement]]): The achievements available within the organization. + parent_org (Optional[Org]): The parent organization. + slack_space (Optional[SlackSpace]): The associated Slack workspace. + """ # noqa: E501 + + __tablename__ = "orgs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + parent_id: Mapped[Optional[int]] = mapped_column(ForeignKey("orgs.id")) + org_type: Mapped[Org_Type] + default_location_id: Mapped[Optional[int]] + name: Mapped[str] + description: Mapped[Optional[text]] + is_active: Mapped[bool] + logo_url: Mapped[Optional[str]] + website: Mapped[Optional[str]] + email: Mapped[Optional[str]] + twitter: Mapped[Optional[str]] + facebook: Mapped[Optional[str]] + instagram: Mapped[Optional[str]] + last_annual_review: Mapped[Optional[date]] + meta: Mapped[Optional[Dict[str, Any]]] + ao_count: Mapped[Optional[int]] = mapped_column(Integer, default=0, nullable=True) + created: Mapped[dt_create] + updated: Mapped[dt_update] + + __table_args__ = ( + Index("idx_orgs_parent_id", "parent_id"), + Index("idx_orgs_org_type", "org_type"), + Index("idx_orgs_is_active", "is_active"), + ) + + locations: Mapped[Optional[List["Location"]]] = relationship("Location", cascade="expunge") + event_types: Mapped[Optional[List["EventType"]]] = relationship( + "EventType", + primaryjoin="or_(EventType.specific_org_id == Org.id, EventType.specific_org_id.is_(None))", + cascade="expunge", + viewonly=True, + ) + event_tags: Mapped[Optional[List["EventTag"]]] = relationship( + "EventTag", + primaryjoin="or_(EventTag.specific_org_id == Org.id, EventTag.specific_org_id.is_(None))", + cascade="expunge", + viewonly=True, + ) + achievements: Mapped[Optional[List["Achievement"]]] = relationship( + "Achievement", + cascade="expunge", + primaryjoin="or_(Achievement.specific_org_id == Org.id, Achievement.specific_org_id.is_(None))", + ) + parent_org: Mapped[Optional["Org"]] = relationship("Org", remote_side=[id], cascade="expunge") + slack_space: Mapped[Optional["SlackSpace"]] = relationship( + "SlackSpace", secondary="orgs_x_slack_spaces", cascade="expunge" + ) + + +class EventType(Base): + """ + Model representing an event type. Event types can be shared by regions or not, and should roll up into event categories. + + Attributes: + id (int): Primary Key of the model. + name (str): The name of the event type. + description (Optional[text]): A description of the event type. + acronym (Optional[str]): Acronyms associated with the event type. + event_category (Event_Category): The category of the event type (first_f, second_f, third_f). + specific_org_id (Optional[int]): The ID of the specific organization. + is_active (bool): Whether the event type is active. Default is True. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "event_types" + + id: Mapped[intpk] + name: Mapped[str] + description: Mapped[Optional[text]] + acronym: Mapped[Optional[str]] + event_category: Mapped[Event_Category] + specific_org_id: Mapped[Optional[int]] = mapped_column(ForeignKey("orgs.id")) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class EventType_x_Event(Base): + """ + Model representing the association between events and event types. The intention is that a single event can be associated with multiple event types. + + Attributes: + event_id (int): The ID of the associated event. + event_type_id (int): The ID of the associated event type. + + event (Event): The associated event. + """ # noqa: E501 + + __tablename__ = "events_x_event_types" + + event_id: Mapped[int] = mapped_column(ForeignKey("events.id", onupdate="CASCADE"), primary_key=True) + event_type_id: Mapped[int] = mapped_column(ForeignKey("event_types.id"), primary_key=True) + __table_args__ = ( + Index("idx_events_x_event_types_event_id", "event_id"), + Index("idx_events_x_event_types_event_type_id", "event_type_id"), + ) + + event: Mapped["Event"] = relationship(back_populates="event_x_event_types") + + +class EventType_x_EventInstance(Base): + """ + Model representing the association between event instances and event types. The intention is that a single event instance can be associated with multiple event types. + + Attributes: + event_instance_id (int): The ID of the associated event instance. + event_type_id (int): The ID of the associated event type. + + event_instance (EventInstance): The associated event instance. + """ # noqa: E501 + + __tablename__ = "event_instances_x_event_types" + + event_instance_id: Mapped[int] = mapped_column( + ForeignKey("event_instances.id", onupdate="CASCADE"), primary_key=True + ) + event_type_id: Mapped[int] = mapped_column(ForeignKey("event_types.id"), primary_key=True) + + event_instance: Mapped["EventInstance"] = relationship(back_populates="event_instances_x_event_types") + + +class EventTag(Base): + """ + Model representing an event tag. These are used to mark special events, such as anniversaries or special workouts. + + Attributes: + id (int): Primary Key of the model. + name (str): The name of the event tag. + description (Optional[text]): A description of the event tag. + color (Optional[str]): The color used for the calendar. + specific_org_id (Optional[int]): Used for custom tags for specific regions. + is_active (bool): Whether the event tag is active. Default is True. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ + + __tablename__ = "event_tags" + + id: Mapped[intpk] + name: Mapped[str] + description: Mapped[Optional[text]] + color: Mapped[Optional[str]] + specific_org_id: Mapped[Optional[int]] = mapped_column(ForeignKey("orgs.id")) + is_active: Mapped[bool] = mapped_column(Boolean, server_default="true", nullable=False) + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class EventTag_x_Event(Base): + """ + Model representing the association between event tags and events. The intention is that a single event can be associated with multiple event tags. + + Attributes: + event_id (int): The ID of the associated event. + event_tag_id (int): The ID of the associated event tag. + + event (Event): The associated event. + """ # noqa: E501 + + __tablename__ = "event_tags_x_events" + + event_id: Mapped[int] = mapped_column(ForeignKey("events.id", onupdate="CASCADE"), primary_key=True) + event_tag_id: Mapped[int] = mapped_column(ForeignKey("event_tags.id"), primary_key=True) + + event: Mapped["Event"] = relationship(back_populates="event_x_event_tags") + + +class EventTag_x_EventInstance(Base): + """ + Model representing the association between event tags and event instances. The intention is that a single event instance can be associated with multiple event tags. + + Attributes: + event_instance_id (int): The ID of the associated event instance. + event_tag_id (int): The ID of the associated event tag. + + event_instance (EventInstance): The associated event instance. + """ # noqa: E501 + + __tablename__ = "event_tags_x_event_instances" + + event_instance_id: Mapped[int] = mapped_column( + ForeignKey("event_instances.id", onupdate="CASCADE"), primary_key=True + ) + event_tag_id: Mapped[int] = mapped_column(ForeignKey("event_tags.id"), primary_key=True) + + event_instance: Mapped["EventInstance"] = relationship(back_populates="event_instances_x_event_tags") + + +class Org_x_SlackSpace(Base): + """ + Model representing the association between organizations and Slack workspaces. This is currently meant to be one to one, but theoretically could support multiple workspaces per organization. + + Attributes: + org_id (int): The ID of the associated organization. + slack_space_id (str): The ID of the associated Slack workspace. + """ # noqa: E501 + + __tablename__ = "orgs_x_slack_spaces" + + org_id: Mapped[int] = mapped_column(ForeignKey("orgs.id"), primary_key=True) + slack_space_id: Mapped[int] = mapped_column(ForeignKey("slack_spaces.id"), primary_key=True) + + +class Location(Base): + """ + Model representing a location. Locations are expected to belong to a single organization (region). + + Attributes: + id (int): Primary Key of the model. + org_id (int): The ID of the associated organization. + name (str): The name of the location. + description (Optional[text]): A description of the location. + is_active (bool): Whether the location is active. + email (Optional[str]): A contact email address associated with the location. + lat (Optional[float]): The latitude of the location. + lon (Optional[float]): The longitude of the location. + address_street (Optional[str]): The street address of the location. + address_city (Optional[str]): The city of the location. + address_state (Optional[str]): The state of the location. + address_zip (Optional[str]): The ZIP code of the location. + address_country (Optional[str]): The country of the location. + meta (Optional[Dict[str, Any]]): Additional metadata for the location. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "locations" + + id: Mapped[intpk] + org_id: Mapped[int] = mapped_column(ForeignKey("orgs.id")) + name: Mapped[str] + description: Mapped[Optional[text]] + is_active: Mapped[bool] + email: Mapped[Optional[str]] + latitude: Mapped[Optional[float]] = mapped_column(Float(precision=8, decimal_return_scale=5)) + longitude: Mapped[Optional[float]] = mapped_column(Float(precision=8, decimal_return_scale=5)) + address_street: Mapped[Optional[str]] + address_street2: Mapped[Optional[str]] + address_city: Mapped[Optional[str]] + address_state: Mapped[Optional[str]] + address_zip: Mapped[Optional[str]] + address_country: Mapped[Optional[str]] + meta: Mapped[Optional[Dict[str, Any]]] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + __table_args__ = ( + Index("idx_locations_org_id", "org_id"), + Index("idx_locations_name", "name"), + Index("idx_locations_is_active", "is_active"), + ) + + +class Event(Base): + """ + Model representing an event or series; the same model is used for both with a self-referential relationship for series. + + Attributes: + id (int): Primary Key of the model. + org_id (int): The ID of the associated organization. + location_id (Optional[int]): The ID of the associated location. + series_id (Optional[int]): The ID of the associated event series. + is_series (bool): Whether this record is a series or single occurrence. Default is False. + is_active (bool): Whether the event is active. Default is True. + highlight (bool): Whether the event is highlighted. Default is False. + start_date (date): The start date of the event. + end_date (Optional[date]): The end date of the event. + start_time (Optional[str]): The start time of the event. Format is 'HHMM', 24-hour time, timezone naive. + end_time (Optional[str]): The end time of the event. Format is 'HHMM', 24-hour time, timezone naive. + day_of_week (Optional[Day_Of_Week]): The day of the week of the event. + name (str): The name of the event. + description (Optional[text]): A description of the event. + email (Optional[str]): A contact email address associated with the event. + recurrence_pattern (Optional[Event_Cadence]): The recurrence pattern of the event. Current options are 'weekly' or 'monthly'. + recurrence_interval (Optional[int]): The recurrence interval of the event (e.g. every 2 weeks). + index_within_interval (Optional[int]): The index within the recurrence interval. (e.g. 2nd Tuesday of the month). + pax_count (Optional[int]): The number of participants. + fng_count (Optional[int]): The number of first-time participants. + is_private (bool): Whether the event is private (won't be shown on maps, etc.). Default is False. + meta (Optional[Dict[str, Any]]): Additional metadata for the event. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + + org (Org): The associated organization. + location (Location): The associated location. + event_types (List[EventType]): The associated event types. + event_tags (Optional[List[EventTag]]): The associated event tags. + event_x_event_types (List[EventType_x_Event]): The association between the event and event types. + event_x_event_tags (Optional[List[EventTag_x_Event]]): The association between the event and event tags. + """ # noqa: E501 + + __tablename__ = "events" + + id: Mapped[intpk] + org_id: Mapped[int] = mapped_column(ForeignKey("orgs.id")) + location_id: Mapped[Optional[int]] = mapped_column(ForeignKey("locations.id")) + series_id: Mapped[Optional[int]] = mapped_column(ForeignKey("events.id")) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + highlight: Mapped[bool] = mapped_column(Boolean, default=False) + start_date: Mapped[date] + end_date: Mapped[Optional[date]] + start_time: Mapped[Optional[str]] + end_time: Mapped[Optional[str]] + day_of_week: Mapped[Optional[Day_Of_Week]] + name: Mapped[str] + description: Mapped[Optional[text]] + email: Mapped[Optional[str]] + recurrence_pattern: Mapped[Optional[Event_Cadence]] + recurrence_interval: Mapped[Optional[int]] + index_within_interval: Mapped[Optional[int]] + is_private: Mapped[bool] = mapped_column(Boolean, server_default="false", nullable=False) + meta: Mapped[Optional[Dict[str, Any]]] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + __table_args__ = ( + Index("idx_events_org_id", "org_id"), + Index("idx_events_location_id", "location_id"), + Index("idx_events_is_active", "is_active"), + ) + + org: Mapped[Org] = relationship(innerjoin=True, cascade="expunge", viewonly=True) + location: Mapped[Location] = relationship(innerjoin=False, cascade="expunge", viewonly=True) + event_types: Mapped[List[EventType]] = relationship( + secondary="events_x_event_types", + innerjoin=True, + cascade="expunge", + viewonly=True, + ) + event_tags: Mapped[Optional[List[EventTag]]] = relationship( + secondary="event_tags_x_events", cascade="expunge", viewonly=True + ) + event_x_event_types: Mapped[List[EventType_x_Event]] = relationship( + back_populates="event", + passive_deletes=True, + cascade="all, delete-orphan", + ) + event_x_event_tags: Mapped[Optional[List[EventTag_x_Event]]] = relationship( + back_populates="event", + passive_deletes=True, + cascade="all, delete-orphan", + ) + + +class EventInstance(Base): + """ + Model representing an event instance (a single occurrence of an event). + + Attributes: + id (int): Primary Key of the model. + org_id (int): The ID of the associated organization. + location_id (Optional[int]): The ID of the associated location. + series_id (Optional[int]): The ID of the associated event series. + is_active (bool): Whether the event is active. Default is True. + highlight (bool): Whether the event is highlighted. Default is False. + start_date (date): The start date of the event. + end_date (Optional[date]): The end date of the event. + start_time (Optional[str]): The start time of the event. Format is 'HHMM', 24-hour time, timezone naive. + end_time (Optional[str]): The end time of the event. Format is 'HHMM', 24-hour time, timezone naive. + name (str): The name of the event. + description (Optional[text]): A description of the event. + email (Optional[str]): A contact email address associated with the event. + pax_count (Optional[int]): The number of participants. + fng_count (Optional[int]): The number of first-time participants. + preblast (Optional[text]): The pre-event announcement. + backblast (Optional[text]): The post-event report. + preblast_rich (Optional[Dict[str, Any]]): The rich text pre-event announcement (e.g. Slack message). + backblast_rich (Optional[Dict[str, Any]]): The rich text post-event report (e.g. Slack message). + preblast_ts (Optional[float]): The Slack post timestamp of the pre-event announcement. + backblast_ts (Optional[float]): The Slack post timestamp of the post-event report. + series_exception (Optional[Series_Exception]): Any exceptions to the normal series pattern for this event instance. + meta (Optional[Dict[str, Any]]): Additional metadata for the event. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + + org (Org): The associated organization. + location (Location): The associated location. + event_types (List[EventType]): The associated event types. + event_tags (Optional[List[EventTag]]): The associated event tags. + event_instances_x_event_types (List[EventType_x_EventInstance]): The association between the event and event types. + event_instances_x_event_tags (Optional[List[EventTag_x_EventInstance]]): The association between the event and event tags. + """ # noqa: E501 + + __tablename__ = "event_instances" + __table_args__ = ( + Index("idx_event_instances_org_id", "org_id"), + Index("idx_event_instances_location_id", "location_id"), + Index("idx_event_instances_is_active", "is_active"), + ) + + id: Mapped[intpk] + org_id: Mapped[int] = mapped_column(ForeignKey("orgs.id")) + location_id: Mapped[Optional[int]] = mapped_column(ForeignKey("locations.id")) + series_id: Mapped[Optional[int]] = mapped_column(ForeignKey("events.id", onupdate="CASCADE")) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + highlight: Mapped[bool] = mapped_column(Boolean, default=False) + start_date: Mapped[date] + end_date: Mapped[Optional[date]] + start_time: Mapped[Optional[str]] + end_time: Mapped[Optional[str]] + name: Mapped[str] + description: Mapped[Optional[text]] + email: Mapped[Optional[str]] + pax_count: Mapped[Optional[int]] + fng_count: Mapped[Optional[int]] + preblast: Mapped[Optional[text]] + backblast: Mapped[Optional[text]] + preblast_rich: Mapped[Optional[Dict[str, Any]]] + backblast_rich: Mapped[Optional[Dict[str, Any]]] + preblast_ts: Mapped[Optional[float]] + backblast_ts: Mapped[Optional[float]] + is_private: Mapped[bool] = mapped_column(Boolean, server_default="false", nullable=False) + series_exception: Mapped[Optional[Series_Exception]] + meta: Mapped[Optional[Dict[str, Any]]] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + __table_args__ = ( + Index("idx_event_instances_org_id", "org_id"), + Index("idx_event_instances_location_id", "location_id"), + Index("idx_event_instances_is_active", "is_active"), + ) + + org: Mapped[Org] = relationship(innerjoin=True, cascade="expunge", viewonly=True) + location: Mapped[Location] = relationship(innerjoin=False, cascade="expunge", viewonly=True) + event_types: Mapped[List[EventType]] = relationship( + secondary="event_instances_x_event_types", + innerjoin=True, + cascade="expunge", + viewonly=True, + ) + event_tags: Mapped[Optional[List[EventTag]]] = relationship( + secondary="event_tags_x_event_instances", cascade="expunge", viewonly=True + ) + event_instances_x_event_types: Mapped[List[EventType_x_EventInstance]] = relationship( + back_populates="event_instance", + passive_deletes=True, + cascade="all, delete-orphan", + ) + event_instances_x_event_tags: Mapped[Optional[List[EventTag_x_EventInstance]]] = relationship( + back_populates="event_instance", + passive_deletes=True, + cascade="all, delete-orphan", + ) + attendance: Mapped[List["Attendance"]] = relationship( + back_populates="event_instance", + passive_deletes=True, + cascade="all, delete-orphan", + ) + + +class EventInstanceExpanded(Base): + """ + Read-only ORM mapping for the materialized view `event_instance_expanded`. + + This view expands each event instance with series, org hierarchy, location, + aggregated type/tag indicators, and arrays of names. It is intended for + querying only and should not be used for inserts/updates. + """ + + __tablename__ = "event_instance_expanded" + + # Base event-instance level fields + id: Mapped[int] = mapped_column(Integer, primary_key=True) + org_id: Mapped[int] = mapped_column(Integer) + location_id: Mapped[Optional[int]] = mapped_column(Integer) + series_id: Mapped[Optional[int]] = mapped_column(Integer) + highlight: Mapped[bool] = mapped_column(Boolean) + start_date: Mapped[date] + end_date: Mapped[Optional[date]] + start_time: Mapped[Optional[str]] + end_time: Mapped[Optional[str]] + name: Mapped[str] + description: Mapped[Optional[str]] + pax_count: Mapped[Optional[int]] + fng_count: Mapped[Optional[int]] + preblast: Mapped[Optional[str]] + backblast: Mapped[Optional[str]] + meta: Mapped[Optional[Dict[str, Any]]] + created: Mapped[datetime] = mapped_column(DateTime) + updated: Mapped[datetime] = mapped_column(DateTime) + + # Series fields + series_name: Mapped[Optional[str]] + series_description: Mapped[Optional[str]] + + # AO fields (org_type = 'ao') + ao_org_id: Mapped[Optional[int]] = mapped_column(Integer) + ao_name: Mapped[Optional[str]] + ao_description: Mapped[Optional[str]] + ao_logo_url: Mapped[Optional[str]] + ao_website: Mapped[Optional[str]] + ao_meta: Mapped[Optional[Dict[str, Any]]] + + # Region fields (coalesce of direct region or parent of AO) + region_org_id: Mapped[Optional[int]] = mapped_column(Integer) + region_name: Mapped[Optional[str]] + region_description: Mapped[Optional[str]] + region_logo_url: Mapped[Optional[str]] + region_website: Mapped[Optional[str]] + region_meta: Mapped[Optional[Dict[str, Any]]] + + # Area and sector fields + area_org_id: Mapped[Optional[int]] = mapped_column(Integer) + area_name: Mapped[Optional[str]] + sector_org_id: Mapped[Optional[int]] = mapped_column(Integer) + sector_name: Mapped[Optional[str]] + + # Location fields + location_name: Mapped[Optional[str]] + location_description: Mapped[Optional[str]] + location_latitude: Mapped[Optional[float]] = mapped_column(Float) + location_longitude: Mapped[Optional[float]] = mapped_column(Float) + + # Aggregated indicators from event types (int8 in PG -> BigInteger) + bootcamp_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + run_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + ruck_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + first_f_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + second_f_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + third_f_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + + # Aggregated indicators from event tags (int8 in PG -> BigInteger) + pre_workout_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + off_the_books_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + vq_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + convergence_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + + # Arrays of type/tag names + all_types: Mapped[Optional[List[str]]] = mapped_column(ARRAY(VARCHAR)) + all_tags: Mapped[Optional[List[str]]] = mapped_column(ARRAY(VARCHAR)) + + +class AttendanceType(Base): + """ + Model representing an attendance type. Basic types are 1='PAX', 2='Q', 3='Co-Q' + + Attributes: + type (str): The type of attendance. + description (Optional[str]): A description of the attendance type. + """ # noqa: E501 + + __tablename__ = "attendance_types" + + id: Mapped[intpk] + type: Mapped[str] + description: Mapped[Optional[str]] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class Attendance_x_AttendanceType(Base): + """ + Model representing the association between attendance and attendance types. + + Attributes: + attendance_id (int): The ID of the associated attendance. + attendance_type_id (int): The ID of the associated attendance type. + + attendance (Attendance): The associated attendance. + """ # noqa: E501 + + __tablename__ = "attendance_x_attendance_types" + + attendance_id: Mapped[int] = mapped_column(ForeignKey("attendance.id", onupdate="CASCADE"), primary_key=True) + attendance_type_id: Mapped[int] = mapped_column(ForeignKey("attendance_types.id"), primary_key=True) + + attendance: Mapped["Attendance"] = relationship(back_populates="attendance_x_attendance_types") + + +class User(Base): + """ + Model representing a user. + + Attributes: + id (int): Primary Key of the model. + f3_name (Optional[str]): The F3 name of the user. + first_name (Optional[str]): The first name of the user. + last_name (Optional[str]): The last name of the user. + email (str): The email of the user. + phone (Optional[str]): The phone number of the user. + home_region_id (Optional[int]): The ID of the home region. + avatar_url (Optional[str]): The URL of the user's avatar. + meta (Optional[Dict[str, Any]]): Additional metadata for the user. + email_verified (Optional[datetime]): The timestamp when the user's email was verified. + status (UserStatus): The status of the user. Default is 'active'. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "users" + + id: Mapped[intpk] + f3_name: Mapped[Optional[str]] + first_name: Mapped[Optional[str]] + last_name: Mapped[Optional[str]] + email: Mapped[str] = mapped_column(CIText, unique=True) + phone: Mapped[Optional[str]] + emergency_contact: Mapped[Optional[str]] + emergency_phone: Mapped[Optional[str]] + emergency_notes: Mapped[Optional[str]] + home_region_id: Mapped[Optional[int]] = mapped_column(ForeignKey("orgs.id")) + avatar_url: Mapped[Optional[str]] + meta: Mapped[Optional[Dict[str, Any]]] + email_verified: Mapped[Optional[datetime]] + status: Mapped[User_Status] = mapped_column(Enum(User_Status), default=User_Status.active) + created: Mapped[dt_create] + updated: Mapped[dt_update] + + home_region_org: Mapped[Optional[Org]] = relationship(cascade="expunge", viewonly=True) + + +class SlackUser(Base): + """ + Model representing a Slack user. + + Attributes: + id (int): Primary Key of the model. + slack_id (str): The Slack ID of the user. + user_name (str): The username of the Slack user. + email (str): The email of the Slack user. + is_admin (bool): Whether the user is an admin. + is_owner (bool): Whether the user is the owner. + is_bot (bool): Whether the user is a bot. + user_id (Optional[int]): The ID of the associated user. + avatar_url (Optional[str]): The URL of the user's avatar. + slack_team_id (str): The ID of the associated Slack team. + strava_access_token (Optional[str]): The Strava access token of the user. + strava_refresh_token (Optional[str]): The Strava refresh token of the user. + strava_expires_at (Optional[datetime]): The expiration time of the Strava token. + strava_athlete_id (Optional[int]): The Strava athlete ID of the user. + meta (Optional[Dict[str, Any]]): Additional metadata for the Slack user. + slack_updated (Optional[int]): The last update time of the Slack user. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "slack_users" + + id: Mapped[intpk] + slack_id: Mapped[str] + user_name: Mapped[str] + email: Mapped[str] + is_admin: Mapped[bool] + is_owner: Mapped[bool] + is_bot: Mapped[bool] + user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id")) + avatar_url: Mapped[Optional[str]] + slack_team_id: Mapped[str] + strava_access_token: Mapped[Optional[str]] + strava_refresh_token: Mapped[Optional[str]] + strava_expires_at: Mapped[Optional[datetime]] + strava_athlete_id: Mapped[Optional[int]] + meta: Mapped[Optional[Dict[str, Any]]] + slack_updated: Mapped[Optional[int]] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class Attendance(Base): + """ + Model representing an attendance record. + + Attributes: + id (int): Primary Key of the model. + event_instance_id (int): The ID of the associated event instance. + user_id (Optional[int]): The ID of the associated user. + is_planned (bool): Whether this is planned attendance (True) vs actual attendance (False). + meta (Optional[Dict[str, Any]]): Additional metadata for the attendance. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + + event_instance (EventInstance): The associated event instance. + user (User): The associated user. + slack_users (Optional[List[SlackUser]]): The associated Slack Users for this User (a User can be in multiple SlackSpaces). + attendance_x_attendance_types (List[Attendance_x_AttendanceType]): The association between the attendance and attendance types. + attendance_types (List[AttendanceType]): The associated attendance types. + """ # noqa: E501 + + __tablename__ = "attendance" + __table_args__ = ( + UniqueConstraint("event_instance_id", "user_id", "is_planned"), + Index("idx_attendance_event_instance_id", "event_instance_id"), + Index("idx_attendance_user_id", "user_id"), + Index("idx_attendance_is_planned", "is_planned"), + ) + + id: Mapped[intpk] + event_instance_id: Mapped[int] = mapped_column(ForeignKey("event_instances.id", onupdate="CASCADE")) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + is_planned: Mapped[bool] + meta: Mapped[Optional[Dict[str, Any]]] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + event_instance: Mapped[EventInstance] = relationship(innerjoin=True, cascade="expunge", viewonly=True) + user: Mapped[User] = relationship(innerjoin=True, cascade="expunge", viewonly=True) + slack_users: Mapped[Optional[List[SlackUser]]] = relationship( + innerjoin=False, cascade="expunge", secondary="users", viewonly=True + ) + attendance_x_attendance_types: Mapped[List[Attendance_x_AttendanceType]] = relationship( + back_populates="attendance", passive_deletes=True, cascade="all, delete-orphan" + ) + attendance_types: Mapped[List[AttendanceType]] = relationship( + secondary="attendance_x_attendance_types", + innerjoin=True, + cascade="expunge", + viewonly=True, + ) + + +class AttendanceExpanded(Base): + """ + Read-only ORM mapping for the materialized view `attendance_expanded`. + + Includes base attendance fields, aggregated attendance-type indicators, + and selected user and home-region details. Query-only. + """ + + __tablename__ = "attendance_expanded" + + # Base attendance fields + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer) + event_instance_id: Mapped[int] = mapped_column(Integer) + attendance_meta: Mapped[Optional[Dict[str, Any]]] + created: Mapped[datetime] = mapped_column(DateTime) + updated: Mapped[datetime] = mapped_column(DateTime) + + # Aggregated indicators (int8 -> BigInteger) + q_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + coq_ind: Mapped[Optional[int]] = mapped_column(BigInteger) + + # Joined user and region info + f3_name: Mapped[Optional[str]] + first_name: Mapped[Optional[str]] + last_name: Mapped[Optional[str]] + email: Mapped[Optional[str]] = mapped_column(CIText) + home_region_id: Mapped[Optional[int]] = mapped_column(Integer) + home_region_name: Mapped[Optional[str]] + avatar_url: Mapped[Optional[str]] + user_status: Mapped[Optional[User_Status]] = mapped_column(Enum(User_Status)) + + +class Achievement(Base): + """ + Model representing an achievement. + + Attributes: + id (int): Primary Key of the model. + name (str): The name of the achievement. + description (Optional[str]): A description of the achievement. + image_url (Optional[str]): The URL of the achievement's image. + specific_org_id (Optional[int]): The ID of the specific region if a custom achievement. If null, the achievement is available to all regions. + is_active (bool): Whether the achievement is active. Default is True. + auto_award (bool): Whether the achievement is automatically awarded or needs to be manually tagged. Default is False. + auto_cadence (Optional[Achievement_Cadence]): The cadence for automatic awarding of the achievement. + auto_threshold (Optional[int]): The threshold for automatic awarding of the achievement. + auto_threshold_type (Optional[Achievement_Threshold_Type]): The type of threshold for automatic awarding of the achievement ('posts', 'unique_aos', etc.). + auto_filters (Optional[Dict[str, Any]]): Event filters for automatic awarding of the achievement. Should be a format like {'include': [{'event_type_id': [1, 2]}, {'event_tag_id': [3]}], 'exclude': [{'event_category': ['third_f']}]}. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "achievements" + + id: Mapped[intpk] + name: Mapped[str] + description: Mapped[Optional[str]] + image_url: Mapped[Optional[str]] + specific_org_id: Mapped[Optional[int]] = mapped_column(ForeignKey("orgs.id")) + is_active: Mapped[bool] = mapped_column(Boolean, server_default="true", nullable=False) + auto_award: Mapped[bool] = mapped_column(Boolean, server_default="false", nullable=False) + auto_cadence: Mapped[Optional[Achievement_Cadence]] + auto_threshold_type: Mapped[Optional[str]] + auto_threshold: Mapped[Optional[int]] + auto_filters: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, default=dict) + meta: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON, default=dict) + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class Achievement_x_User(Base): + """ + Model representing the association between achievements and users. + + Attributes: + achievement_id (int): The ID of the associated achievement. + user_id (int): The ID of the associated user. + award_year (int): The year the achievement was awarded. Default is -1 (used for lifetime achievements). + award_period (int): The period (ie week, month) the achievement was awarded in. Default is -1 (used for lifetime achievements). + date_awarded (date): The date the achievement was awarded. Default is the current date. + """ # noqa: E501 + + __tablename__ = "achievements_x_users" + + achievement_id: Mapped[int] = mapped_column(ForeignKey("achievements.id"), primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) + award_year: Mapped[int] = mapped_column(Integer, primary_key=True, server_default="-1") + award_period: Mapped[int] = mapped_column(Integer, primary_key=True, server_default="-1") + date_awarded: Mapped[date] = mapped_column(DateTime, server_default=func.timezone("utc", func.now())) + + +class Position(Base): + """ + Model representing a position. + + Attributes: + name (str): The name of the position. + description (Optional[str]): A description of the position. + org_type (Optional[Org_Type]): The associated organization type. This is used to limit the positions available to certain types of organizations. If null, the position is available to all organization types. + org_id (Optional[int]): The ID of the associated organization. This is used to limit the positions available to certain organizations. If null, the position is available to all organizations. + is_active (bool): Whether the position is active. Default is True. + """ # noqa: E501 + + __tablename__ = "positions" + + id: Mapped[intpk] + name: Mapped[str] + description: Mapped[Optional[str]] + org_type: Mapped[Optional[Org_Type]] + org_id: Mapped[Optional[int]] = mapped_column(ForeignKey("orgs.id")) + is_active: Mapped[bool] = mapped_column(Boolean, server_default="true", nullable=False) + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class Position_x_Org_x_User(Base): + """ + Model representing the association between positions, organizations, and users. + + Attributes: + position_id (int): The ID of the associated position. + org_id (int): The ID of the associated organization. + user_id (int): The ID of the associated user. + """ # noqa: E501 + + __tablename__ = "positions_x_orgs_x_users" + + position_id: Mapped[int] = mapped_column(ForeignKey("positions.id"), primary_key=True) + org_id: Mapped[int] = mapped_column(ForeignKey("orgs.id"), primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) + + +class Expansion(Base): + """ + Model representing an expansion. + + Attributes: + id (int): Primary Key of the model. + area (str): The area of the expansion. + pinned_lat (float): The pinned latitude of the expansion. + pinned_lon (float): The pinned longitude of the expansion. + user_lat (float): The user's latitude. + user_lon (float): The user's longitude. + interested_in_organizing (bool): Whether the user is interested in organizing. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "expansions" + + id: Mapped[intpk] + area: Mapped[str] + pinned_lat: Mapped[float] + pinned_lon: Mapped[float] + user_lat: Mapped[float] + user_lon: Mapped[float] + interested_in_organizing: Mapped[bool] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class Expansion_x_User(Base): + """ + Model representing the association between expansions and users. + + Attributes: + expansion_id (int): The ID of the associated expansion. + user_id (int): The ID of the associated user. + requst_date (date): The date of the request. Default is the current date. + notes (Optional[text]): Additional notes for the association. + """ # noqa: E501 + + __tablename__ = "expansions_x_users" + + expansion_id: Mapped[int] = mapped_column(ForeignKey("expansions.id"), primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True) + request_date: Mapped[date] = mapped_column(DateTime, server_default=func.timezone("utc", func.now())) + notes: Mapped[Optional[text]] + + +class NextAuthAccount(Base): + """ + Model representing an authentication account. + + Attributes: + user_id (int): The ID of the associated user. + type (text): The type of the account. + provider (text): The provider of the account. + provider_account_id (text): The provider account ID. + refresh_token (Optional[text]): The refresh token. + access_token (Optional[text]): The access token. + expires_at (Optional[datetime]): The expiration time of the token. + token_type (Optional[text]): The token type. + scope (Optional[text]): The scope of the token. + id_token (Optional[text]): The ID token. + session_state (Optional[text]): The session state. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "auth_accounts" + + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + type: Mapped[text] # need adapter account_type? + provider: Mapped[text] = mapped_column(VARCHAR, primary_key=True) + provider_account_id: Mapped[text] = mapped_column(VARCHAR, primary_key=True) + refresh_token: Mapped[Optional[text]] + access_token: Mapped[Optional[text]] + expires_at: Mapped[Optional[datetime]] + token_type: Mapped[Optional[text]] + scope: Mapped[Optional[text]] + id_token: Mapped[Optional[text]] + session_state: Mapped[Optional[text]] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class NextAuthSession(Base): + """ + Model representing an authentication session. + + Attributes: + session_token (text): The session token. + user_id (int): The ID of the associated user. + expires (ts_notz): The expiration time of the session. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "auth_sessions" + + session_token: Mapped[text] = mapped_column(TEXT, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + expires: Mapped[ts_notz] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class NextAuthVerificationToken(Base): + """ + Model representing an authentication verification token. + + Attributes: + identifier (text): The identifier of the token. + token (text): The token. + expires (ts_notz): The expiration time of the token. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "auth_verification_tokens" + + identifier: Mapped[text] = mapped_column(VARCHAR, primary_key=True) + token: Mapped[text] = mapped_column(VARCHAR, primary_key=True) + expires: Mapped[ts_notz] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class UpdateRequest(Base): + """ + Model representing an update request. + + Attributes: + id (UUID): The ID of the update request. + token (UUID): The token of the update request. + region_id (int): The ID of the associated region. + event_id (Optional[int]): The ID of the associated event. + event_type_ids (Optional[List[int]]): The associated event type IDs. + event_tag (Optional[str]): The associated event tag. + event_series_id (Optional[int]): The ID of the associated event series. + event_is_series (Optional[bool]): Whether the event is a series. + event_is_active (Optional[bool]): Whether the event is active. + event_highlight (Optional[bool]): Whether the event is highlighted. + event_start_date (Optional[date]): The start date of the event. + event_end_date (Optional[date]): The end date of the event. + event_start_time (Optional[str]): The start time of the event. Format is 'HHMM', 24-hour time, timezone naive. + event_end_time (Optional[str]): The end time of the event. Format is 'HHMM', 24-hour time, timezone naive. + event_day_of_week (Optional[Day_Of_Week]): The day of the week of the event. + event_name (str): The name of the event. + event_description (Optional[text]): A description of the event. + event_recurrence_pattern (Optional[Event_Cadence]): The recurrence pattern of the event. + event_recurrence_interval (Optional[int]): The recurrence interval of the event. + event_index_within_interval (Optional[int]): The index within the recurrence interval. + event_meta (Optional[Dict[str, Any]]): Additional metadata for the event. + event_contact_email (Optional[str]): The contact email of the event. + location_name (Optional[text]): The name of the location. + location_description (Optional[text]): A description of the location. + location_address (Optional[text]): The address of the location. + location_address2 (Optional[text]): The second address line of the location. + location_city (Optional[text]): The city of the location. + location_state (Optional[str]): The state of the location. + location_zip (Optional[str]): The ZIP code of the location. + location_country (Optional[str]): The country of the location. + location_lat (Optional[float]): The latitude of the location. + location_lng (Optional[float]): The longitude of the location. + location_id (Optional[int]): The ID of the location. + location_contact_email (Optional[str]): The contact email of the location. + ao_id (Optional[int]): The ID of the associated AO. + ao_name (Optional[text]): The name of the AO. + ao_logo (Optional[text]): The URL of the AO logo. + ao_website (Optional[text]): The website of the AO. + submitted_by (str): The user who submitted the request. + submitter_validated (Optional[bool]): Whether the submitter has validated the request. Default is False. + reviewed_by (Optional[str]): The user who reviewed the request. + reviewed_at (Optional[datetime]): The timestamp when the request was reviewed. + status (Update_Request_Status): The status of the request. Default is 'pending'. + meta (Optional[Dict[str, Any]]): Additional metadata for the request. + request_type (Request_Type): The type of the request. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "update_requests" + + id: Mapped[Uuid] = mapped_column(UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()) + token: Mapped[Uuid] = mapped_column(UUID(as_uuid=True), server_default=func.gen_random_uuid()) + region_id: Mapped[int] = mapped_column(ForeignKey("orgs.id")) + event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("events.id")) + event_type_ids: Mapped[Optional[List[int]]] = mapped_column(ARRAY(Integer)) + event_tag: Mapped[Optional[str]] + event_series_id: Mapped[Optional[int]] + event_is_series: Mapped[Optional[bool]] + event_is_active: Mapped[Optional[bool]] + event_highlight: Mapped[Optional[bool]] + event_start_date: Mapped[Optional[date]] + event_end_date: Mapped[Optional[date]] + event_start_time: Mapped[Optional[str]] + event_end_time: Mapped[Optional[str]] + event_day_of_week: Mapped[Optional[Day_Of_Week]] + event_name: Mapped[str] + event_description: Mapped[Optional[text]] + event_recurrence_pattern: Mapped[Optional[Event_Cadence]] + event_recurrence_interval: Mapped[Optional[int]] + event_index_within_interval: Mapped[Optional[int]] + event_meta: Mapped[Optional[Dict[str, Any]]] + event_contact_email: Mapped[Optional[str]] + + location_name: Mapped[Optional[text]] + location_description: Mapped[Optional[text]] + location_address: Mapped[Optional[text]] + location_address2: Mapped[Optional[text]] + location_city: Mapped[Optional[text]] + location_state: Mapped[Optional[str]] + location_zip: Mapped[Optional[str]] + location_country: Mapped[Optional[str]] + location_lat: Mapped[Optional[float]] = mapped_column(REAL()) + location_lng: Mapped[Optional[float]] = mapped_column(REAL()) + location_id: Mapped[Optional[int]] = mapped_column(ForeignKey("locations.id")) + location_contact_email: Mapped[Optional[str]] + + ao_id: Mapped[Optional[int]] = mapped_column(ForeignKey("orgs.id")) + ao_name: Mapped[Optional[text]] + ao_logo: Mapped[Optional[text]] + ao_website: Mapped[Optional[text]] + + submitted_by: Mapped[text] + submitter_validated: Mapped[Optional[bool]] = mapped_column(Boolean, default=False) + reviewed_by: Mapped[Optional[text]] + reviewed_at: Mapped[Optional[datetime]] + status: Mapped[Update_Request_Status] = mapped_column( + Enum(Update_Request_Status), default=Update_Request_Status.pending + ) + meta: Mapped[Optional[Dict[str, Any]]] + request_type: Mapped[Request_Type] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +# -- Main table for entries +# CREATE TABLE IF NOT EXISTS codex_entries ( +# id SERIAL PRIMARY KEY, +# title VARCHAR(255) NOT NULL, +# definition TEXT NOT NULL, +# type VARCHAR(50) NOT NULL, +# aliases JSONB DEFAULT '[]'::jsonb, +# video_link TEXT, +# updated_at TIMESTAMP NOT NULL DEFAULT now() +# ); + +# -- Tags used to categorize entries +# CREATE TABLE IF NOT EXISTS codex_tags ( +# id SERIAL PRIMARY KEY, +# name VARCHAR(255) UNIQUE NOT NULL +# ); + +# -- Many-to-many relationship between entries and tags +# CREATE TABLE IF NOT EXISTS codex_entry_tags ( +# entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, +# tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, +# PRIMARY KEY (entry_id, tag_id) +# ); + +# -- User-submitted suggestions (entries, edits, tags, etc.) +# CREATE TABLE IF NOT EXISTS codex_user_submissions ( +# id SERIAL PRIMARY KEY, +# submission_type VARCHAR(50) NOT NULL, +# data JSONB NOT NULL, +# submitter_name VARCHAR(255), +# submitter_email VARCHAR(255), +# timestamp TIMESTAMP NOT NULL DEFAULT now(), +# status VARCHAR(50) NOT NULL DEFAULT 'pending' +# ); + +# -- Internal linking between entries + +# CREATE TABLE IF NOT EXISTS codex_references ( +# id SERIAL PRIMARY KEY, +# from_entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, +# to_entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, +# context TEXT, +# created_at TIMESTAMP NOT NULL DEFAULT now() +# ); + + +class CodexEntry(Base): + """ + Model representing a Codex entry. + + Attributes: + id (int): Primary Key of the model. + title (str): The title of the entry. + definition (text): The definition of the entry. + type (str): The type of the entry. + aliases (Optional[List[str]]): Aliases for the entry. + video_link (Optional[str]): A link to a video related to the entry. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "codex_entries" + + id: Mapped[intpk] + title: Mapped[str] + definition: Mapped[text] + type: Mapped[str] + aliases: Mapped[Optional[List[str]]] = mapped_column(JSONB, server_default="[]") + video_link: Mapped[Optional[str]] + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class CodexTag(Base): + """ + Model representing a Codex tag. + + Attributes: + id (int): Primary Key of the model. + name (str): The name of the tag. + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "codex_tags" + + id: Mapped[intpk] + name: Mapped[str] = mapped_column(VARCHAR, unique=True, nullable=False) + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class CodexEntryTag(Base): + """ + Model representing the association between Codex entries and tags. + + Attributes: + entry_id (int): The ID of the associated Codex entry. + tag_id (int): The ID of the associated Codex tag. + """ # noqa: E501 + + __tablename__ = "codex_entry_tags" + + entry_id: Mapped[int] = mapped_column(ForeignKey("codex_entries.id", ondelete="CASCADE"), primary_key=True) + tag_id: Mapped[int] = mapped_column(ForeignKey("codex_tags.id", ondelete="CASCADE"), primary_key=True) + + +class CodexUserSubmission(Base): + """ + Model representing a user submission for the Codex. + + Attributes: + id (int): Primary Key of the model. + submission_type (str): The type of the submission (e.g., 'entry', 'edit', 'tag'). + data (Dict[str, Any]): The data of the submission in JSON format. + submitter_name (Optional[str]): The name of the submitter. + submitter_email (Optional[str]): The email of the submitter. + submitter_user_id (Optional[int]): The ID of the associated user, if available. + timestamp (datetime): The timestamp when the submission was made. + status (str): The status of the submission (e.g., 'pending', 'approved', 'rejected'). + created (datetime): The timestamp when the record was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "codex_user_submissions" + + id: Mapped[intpk] + submission_type: Mapped[str] + data: Mapped[Dict[str, Any]] = mapped_column(JSON) + submitter_name: Mapped[Optional[str]] + submitter_email: Mapped[Optional[str]] + submitter_user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id")) + timestamp: Mapped[dt_create] + status: Mapped[Codex_Submission_Status] = mapped_column( + Enum(Codex_Submission_Status), default=Codex_Submission_Status.pending + ) + created: Mapped[dt_create] + updated: Mapped[dt_update] + + +class CodexReference(Base): + """ + Model representing a reference between Codex entries. + + Attributes: + id (int): Primary Key of the model. + from_entry_id (int): The ID of the entry from which the reference originates. + to_entry_id (int): The ID of the entry to which the reference points. + context (Optional[str]): Context or description of the reference. + created (datetime): The timestamp when the reference was created. + updated (datetime): The timestamp when the record was last updated. + """ # noqa: E501 + + __tablename__ = "codex_references" + + id: Mapped[intpk] + from_entry_id: Mapped[int] = mapped_column(ForeignKey("codex_entries.id", ondelete="CASCADE")) + to_entry_id: Mapped[int] = mapped_column(ForeignKey("codex_entries.id", ondelete="CASCADE")) + context: Mapped[Optional[str]] + created: Mapped[dt_create] + updated: Mapped[dt_update] diff --git a/packages/db-python/f3_data_models/testing.py b/packages/db-python/f3_data_models/testing.py new file mode 100644 index 00000000..bb1d3d22 --- /dev/null +++ b/packages/db-python/f3_data_models/testing.py @@ -0,0 +1,24 @@ +from datetime import date + +from sqlalchemy import or_ + +from f3_data_models.models import Event, Org +from f3_data_models.utils import DbManager + + +def test(): + org_id = 25272 + event_records = DbManager.find_records( + Event, + filters=[ + Event.is_active, + or_(Event.org_id == org_id, Event.org.has(Org.parent_id == org_id)), + or_(Event.end_date >= date.today(), Event.end_date.is_(None)), + ], + joinedloads="all", + ) + print(f"Found {len(event_records)} active events for org_id {org_id}.") + + +if __name__ == "__main__": + test() diff --git a/packages/db-python/f3_data_models/utils.py b/packages/db-python/f3_data_models/utils.py new file mode 100755 index 00000000..b798882e --- /dev/null +++ b/packages/db-python/f3_data_models/utils.py @@ -0,0 +1,394 @@ +import logging +import os +import sys +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Generic, List, Optional, Tuple, Type, TypeVar # noqa + +import sqlalchemy +from sqlalchemy import Select, and_, inspect, select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.engine import Engine +from sqlalchemy.orm import class_mapper, joinedload, sessionmaker +from sqlalchemy.orm.collections import InstrumentedList + +from f3_data_models.models import Base + +logging_level = logging.DEBUG if os.environ.get("LOG_LEVEL", "INFO").upper() == "DEBUG" else logging.INFO +logging.getLogger("google.cloud.sql.connector").setLevel(logging_level) +logging.basicConfig(stream=sys.stdout, level=logging_level) + + +@dataclass +class DatabaseField: + name: str + value: object = None + + +ENGINE_CACHE: dict[tuple[str, bool], Engine] = {} +SESSION_FACTORY_CACHE: dict[str, sessionmaker] = {} + +SUPPORTED_BACKENDS = {"postgresql", "bigquery"} +READ_ONLY_BACKENDS = {"bigquery"} + + +def _normalize_backend(backend: str | None) -> str: + selected_backend = "postgresql" if backend is None else backend.lower() + if selected_backend == "postgres": + selected_backend = "postgresql" + if selected_backend not in SUPPORTED_BACKENDS: + supported = ", ".join(sorted(SUPPORTED_BACKENDS)) + raise ValueError(f"Unsupported backend '{backend}'. Supported backends: {supported}") + return selected_backend + + +def _default_echo() -> bool: + return os.environ.get("SQL_ECHO", "False").lower() == "true" + + +def _create_postgresql_engine(echo: bool) -> Engine: + host = os.environ["DATABASE_HOST"] + user = os.environ["DATABASE_USER"] + passwd = os.environ["DATABASE_PASSWORD"] + database = os.environ["DATABASE_SCHEMA"] + port = os.environ.get("DATABASE_PORT", "5432") + + if os.environ.get("USE_GCP_AUTH_PROXY", "false").lower() == "false": + db_url = sqlalchemy.engine.URL.create( + drivername="postgresql", + username=user, + password=passwd, + host=host, + port=port, + database=database, + ) + return sqlalchemy.create_engine(db_url, echo=echo) + + # Connect via Cloud Run's built-in Cloud SQL Auth Proxy Unix socket + unix_sock_dir = f"/cloudsql/{host}" + db_url = sqlalchemy.engine.URL.create( + drivername="postgresql", + username=user, + password=passwd, + database=database, + ) + return sqlalchemy.create_engine( + db_url, + echo=echo, + connect_args={"host": unix_sock_dir}, + ) + + +def _create_bigquery_engine(echo: bool) -> Engine: + project = os.environ["BIGQUERY_PROJECT"] + dataset = os.environ["BIGQUERY_DATASET"] + db_url = f"bigquery://{project}/{dataset}" + return sqlalchemy.create_engine(db_url, echo=echo) + + +def get_engine(backend: str | None = None, echo: bool | None = None) -> Engine: + selected_backend = _normalize_backend(backend) + selected_echo = _default_echo() if echo is None else echo + cache_key = (selected_backend, selected_echo) + if cache_key in ENGINE_CACHE: + return ENGINE_CACHE[cache_key] + + if selected_backend == "postgresql": + engine = _create_postgresql_engine(echo=selected_echo) + else: + engine = _create_bigquery_engine(echo=selected_echo) + + ENGINE_CACHE[cache_key] = engine + return engine + + +def get_session(backend: str | None = None): + selected_backend = _normalize_backend(backend) + session_factory = SESSION_FACTORY_CACHE.get(selected_backend) + if session_factory is None: + session_factory = sessionmaker(bind=get_engine(backend=selected_backend)) + SESSION_FACTORY_CACHE[selected_backend] = session_factory + return session_factory() + + +def _require_write_backend(backend: str | None) -> None: + selected_backend = _normalize_backend(backend) + if selected_backend in READ_ONLY_BACKENDS: + raise NotImplementedError(f"Write operations are not supported for backend '{selected_backend}'.") + + +@contextmanager +def session_scope(backend: str | None = None): + """Provide a transactional scope around a series of operations.""" + session = get_session(backend=backend) + try: + yield session + session.commit() + except Exception as e: + session.rollback() + raise e + finally: + session.close() + + +T = TypeVar("T") + + +def _joinedloads(cls: T, query: Select, joinedloads: list | str = None) -> Select: + if joinedloads is None: + return query + if joinedloads == "all": + joinedloads = [getattr(cls, relationship.key) for relationship in cls.__mapper__.relationships] + return query.options(*[joinedload(load) for load in joinedloads]) + + +class DbManager: + @staticmethod + def get(cls: Type[T], id: int, joinedloads: list | str = None, backend: str | None = None) -> T: + with session_scope(backend=backend) as session: + query = select(cls).filter(cls.id == id) + query = _joinedloads(cls, query, joinedloads) + record = session.scalars(query).unique().one() + session.expunge(record) + return record + + @staticmethod + def find_records( + cls: T, + filters: Optional[List], + joinedloads: List | str = None, + backend: str | None = None, + ) -> List[T]: + with session_scope(backend=backend) as session: + query = select(cls) + query = _joinedloads(cls, query, joinedloads) + query = query.filter(*filters) + records = session.scalars(query).unique().all() + for r in records: + session.expunge(r) + return records + + @staticmethod + def find_first_record( + cls: T, + filters: Optional[List], + joinedloads: List | str = None, + backend: str | None = None, + ) -> T: + with session_scope(backend=backend) as session: + query = select(cls) + query = _joinedloads(cls, query, joinedloads) + query = query.filter(*filters) + record = session.scalars(query).unique().first() + if record: + session.expunge(record) + return record + + @staticmethod + def find_join_records2(left_cls: T, right_cls: T, filters, backend: str | None = None) -> List[Tuple[T]]: + with session_scope(backend=backend) as session: + result = session.execute(select(left_cls, right_cls).join(right_cls).filter(and_(*filters))) + records = result.all() + session.expunge_all() + return records + + @staticmethod + def find_join_records3( + left_cls: T, + right_cls1: T, + right_cls2: T, + filters, + left_join=False, + backend: str | None = None, + ) -> List[Tuple[T]]: + with session_scope(backend=backend) as session: + result = session.execute( + select(left_cls, right_cls1, right_cls2) + .select_from(left_cls) + .join(right_cls1, isouter=left_join) + .join(right_cls2, isouter=left_join) + .filter(and_(*filters)) + ) + records = result.all() + session.expunge_all() + return records + + @staticmethod + def update_record(cls: T, id, fields, backend: str | None = None): + _require_write_backend(backend) + with session_scope(backend=backend) as session: + record = session.get(cls, id) + if not record: + raise ValueError(f"Record with id {id} not found in {cls.__name__}") + + mapper = class_mapper(cls) + relationships = mapper.relationships.keys() + for attr, value in fields.items(): + key = attr if isinstance(attr, str) else attr.key + print(f"key: {key}, value: {value}") + if hasattr(cls, key) and key not in relationships: + setattr(record, key, value) + elif key in relationships: + # Handle relationships separately + relationship = mapper.relationships[key] + related_class = relationship.mapper.class_ + # find mapping of related_class + og_primary_key = None + for k in related_class.__table__.foreign_keys: + if k.references(cls.__table__): + og_primary_key = k.constraint.columns[0].name + break + + if isinstance(value, list) and og_primary_key: + # Delete existing related records + related_class = relationship.mapper.class_ + related_relationships = class_mapper(related_class).relationships.keys() + session.query(related_class).filter(getattr(related_class, og_primary_key) == id).delete() + # Add new related records + items = [item.__dict__ for item in value] + for related_item in items: + update_dict = { + k: v + for k, v in related_item.items() + if hasattr(related_class, k) and k not in related_relationships + } + related_record = related_class(**{og_primary_key: id, **update_dict}) + session.add(related_record) + + @staticmethod + def update_records(cls, filters, fields, backend: str | None = None): + _require_write_backend(backend) + with session_scope(backend=backend) as session: + objects = session.scalars(select(cls).filter(and_(*filters))).all() + + # Get the list of valid attributes for the class + valid_attributes = {attr.key for attr in inspect(cls).mapper.column_attrs} + valid_relationships = {rel.key for rel in inspect(cls).mapper.relationships} + + for obj in objects: + # Update simple fields + for attr, value in fields.items(): + key = attr if isinstance(attr, str) else attr.key + if key in valid_attributes and not isinstance(value, InstrumentedList): + setattr(obj, key, value) + + # Update relationships separately + for attr, value in fields.items(): + key = attr if isinstance(attr, str) else attr.key + if key in valid_relationships: + # Handle relationships separately + relationship = inspect(cls).mapper.relationships[key] + related_class = relationship.mapper.class_ + # find mapping of related_class + og_primary_key = None + for k in related_class.__table__.foreign_keys: + if k.references(cls.__table__): + og_primary_key = k.constraint.columns[0].name + break + + if isinstance(value, list) and og_primary_key: + # Delete existing related records + related_class = relationship.mapper.class_ + related_relationships = class_mapper(related_class).relationships.keys() + session.query(related_class).filter( + getattr(related_class, og_primary_key) == obj.id + ).delete() + # Add new related records + items = [item.__dict__ for item in value] + for related_item in items: + update_dict = { + k: v + for k, v in related_item.items() + if hasattr(related_class, k) and k not in related_relationships + } + related_record = related_class(**{og_primary_key: obj.id, **update_dict}) + session.add(related_record) + + session.flush() + + @staticmethod + def create_record(record: Base, backend: str | None = None) -> Base: + _require_write_backend(backend) + with session_scope(backend=backend) as session: + session.add(record) + session.flush() + session.expunge(record) + return record # noqa + + @staticmethod + def create_records(records: List[Base], backend: str | None = None): + _require_write_backend(backend) + with session_scope(backend=backend) as session: + session.add_all(records) + session.flush() + session.expunge_all() + return records # noqa + + @staticmethod + def create_or_ignore(cls: T, records: List[Base], backend: str | None = None): + _require_write_backend(backend) + with session_scope(backend=backend) as session: + for record in records: + record_dict = {k: v for k, v in record.__dict__.items() if k != "_sa_instance_state"} + stmt = insert(cls).values(record_dict).on_conflict_do_nothing() + session.execute(stmt) + session.flush() + + @staticmethod + def upsert_records(cls, records, backend: str | None = None): + _require_write_backend(backend) + with session_scope(backend=backend) as session: + for record in records: + record_dict = {k: v for k, v in record.__dict__.items() if k != "_sa_instance_state"} + stmt = insert(cls).values(record_dict) + update_dict = {c.name: getattr(record, c.name) for c in cls.__table__.columns} + stmt = stmt.on_conflict_do_update( + index_elements=[cls.__table__.primary_key.columns.keys()], + set_=update_dict, + ) + session.execute(stmt) + session.flush() + + @staticmethod + def delete_record(cls: T, id, backend: str | None = None): + _require_write_backend(backend) + with session_scope(backend=backend) as session: + session.query(cls).filter(cls.id == id).delete() + session.flush() + + @staticmethod + def delete_records(cls: T, filters, joinedloads: List | str = None, backend: str | None = None): + _require_write_backend(backend) + with session_scope(backend=backend) as session: + query = select(cls) + query = _joinedloads(cls, query, joinedloads) + query = query.filter(*filters) + records = session.scalars(query).unique().all() + for r in records: + session.delete(r) + session.flush() + + @staticmethod + def execute_sql_query(sql_query, backend: str | None = None): + with session_scope(backend=backend) as session: + records = session.execute(sql_query) + return records + + +def create_diagram(): + from pydot import Dot + from sqlalchemy_schemadisplay import create_schema_graph + + graph: Dot = create_schema_graph( + engine=get_engine(), + metadata=Base.metadata, + show_datatypes=True, + show_indexes=True, + rankdir="LR", + show_column_keys=True, + ) + graph.write_png("docs/_static/schema_diagram.png") + + +if __name__ == "__main__": + create_diagram() diff --git a/packages/db-python/pyproject.toml b/packages/db-python/pyproject.toml new file mode 100644 index 00000000..83201e1e --- /dev/null +++ b/packages/db-python/pyproject.toml @@ -0,0 +1,61 @@ +[project] +name = "f3-data-models" +version = "1.1.0" +description = "The data schema and models for F3 Nation applications." +readme = "README.md" +requires-python = ">=3.12,<4.0" +license = "MIT" +authors = [{name = "Evan Petzoldt", email = "evan.petzoldt@protonmail.com"}] +dependencies = [ + "sqlalchemy>=2.0.36,<3.0.0", + "psycopg2-binary>=2.9.10,<3.0.0", + "sqlmodel>=0.0.22,<0.0.23", + "sqlalchemy-citext>=1.8.0,<2.0.0", + "pg8000>=1.31.5,<2.0.0", + "cloud-sql-python-connector>=1.20.0,<2.0.0", + "sqlalchemy-bigquery>=1.13.0,<2.0.0", +] + +[project.urls] +Repository = "https://github.com/F3-Nation/f3-data-models" +Documentation = "https://github.io/F3-Nation/f3-data-models" + +[dependency-groups] +dev = [ + "poethepoet>=0.34.0,<0.35.0", +] + +[tool.ruff] +line-length = 120 + +lint.select = [ + "E", # pycodestyle errors (settings from FastAPI, thanks, @tiangolo!) + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "C", # flake8-comprehensions + "B", # flake8-bugbear +] +lint.ignore = [ + "C901", # too complex +] + +[tool.ruff.lint.isort] +order-by-type = true +relative-imports-order = "closest-to-furthest" +extra-standard-library = ["typing"] +section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] +known-first-party = [] + + +[tool.poe.tasks] +install-js = "npm install" +build-js = "npm run build" +install-mermaid-js = "npm install --save-dev @mermaid-js/mermaid-cli" + +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["f3_data_models"] diff --git a/packages/db/src/local-seed-lib/data.ts b/packages/db/src/local-seed-lib/data.ts index fbe770da..75a5af58 100644 --- a/packages/db/src/local-seed-lib/data.ts +++ b/packages/db/src/local-seed-lib/data.ts @@ -207,6 +207,12 @@ export const LOCAL_API_KEYS = [ description: "Used by apps/map for read-only API access", role: "user" as const, }, + { + key: "local-slackbot-key", + name: "Slackbot (local dev)", + description: "Used by apps/slackbot for full Admin access", + role: "admin" as const, + }, ]; // --------------------------------------------------------------------------- diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa931887..399f7fe0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1262,6 +1262,8 @@ importers: specifier: 'catalog:' version: 3.2.4(@types/node@24.12.4)(jsdom@29.1.1)(terser@5.48.0) + apps/slackbot: {} + packages/api: dependencies: '@acme/auth': diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..07caae87 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "f3-nation-python-workspace" +version = "0.0.0" +requires-python = ">=3.12,<4.0" + +[tool.uv.workspace] +members = ["apps/slackbot", "packages/db-python"] + +[tool.uv.sources] +f3-data-models = { workspace = true } diff --git a/release-please-config.json b/release-please-config.json index b0cba35c..e7f525e5 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -37,6 +37,11 @@ "component": "auth", "changelog-path": "CHANGELOG.md", "path": "apps/auth" + }, + "apps/slackbot": { + "component": "slackbot", + "changelog-path": "CHANGELOG.md", + "path": "apps/slackbot" } } } diff --git a/scripts/local-setup.sh b/scripts/local-setup.sh index 83925ba8..a1b15c62 100755 --- a/scripts/local-setup.sh +++ b/scripts/local-setup.sh @@ -26,7 +26,7 @@ echo " ──────────────────────── # ── Step 1: Copy per-directory env files ───────────────────────────────────── echo " → Copying .env.local.example files..." _env_ts=$(date +%Y%m%d%H%M%S) -for dir in apps/api apps/auth apps/map apps/me apps/admin apps/homepage packages/env; do +for dir in apps/api apps/auth apps/map apps/me apps/admin apps/homepage apps/slackbot packages/env; do if [ -f "$dir/.env" ]; then mv "$dir/.env" "$dir/.env.bak.$_env_ts" echo " $dir/.env backed up → $dir/.env.bak.$_env_ts" diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..3dabda18 --- /dev/null +++ b/uv.lock @@ -0,0 +1,4132 @@ +version = 1 +revision = 3 +requires-python = ">=3.12, <4.0" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] + +[manifest] +members = [ + "f3-data-models", + "f3-nation-python-workspace", + "f3-nation-slack-bot", +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[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 = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + +[[package]] +name = "asn1crypto" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080, upload-time = "2022-03-15T14:46:52.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[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 = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "bleach" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + +[[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 = "choreographer" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "logistro" }, + { name = "platformdirs" }, + { name = "simplejson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/69/3058cd4f16d6b75c80e8f95e5b713d930526353ce294df9a7887453ba215/choreographer-1.3.0.tar.gz", hash = "sha256:6c44a0e48e9b37977344d40bfa5a9ed88575fe4bc0fd836771bf702bc24d6884", size = 48291, upload-time = "2026-04-28T22:57:45.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/6c/ff8bf52315064dbeb55cb5067e191120a5b2e58bb648d0d34cf7969dc2c2/choreographer-1.3.0-py3-none-any.whl", hash = "sha256:cea4cb739e4f61625e4b53888a8d3fa1d3bf73948b56753e460ab44da7d8d44f", size = 52622, upload-time = "2026-04-28T22:57:44.015Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click-option-group" +version = "0.5.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/ff/d291d66595b30b83d1cb9e314b2c9be7cfc7327d4a0d40a15da2416ea97b/click_option_group-0.5.9.tar.gz", hash = "sha256:f94ed2bc4cf69052e0f29592bd1e771a1789bd7bfc482dd0bc482134aff95823", size = 22222, upload-time = "2025-10-09T09:38:01.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/45/54bb2d8d4138964a94bef6e9afe48b0be4705ba66ac442ae7d8a8dc4ffef/click_option_group-0.5.9-py3-none-any.whl", hash = "sha256:ad2599248bd373e2e19bec5407967c3eec1d0d4fc4a5e77b08a0481e75991080", size = 11553, upload-time = "2025-10-09T09:38:00.066Z" }, +] + +[[package]] +name = "cloud-sql-python-connector" +version = "1.20.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "cryptography" }, + { name = "dnspython" }, + { name = "google-auth" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/87/3f424bab34980b7996514d5232134ef868e412a9dcde1ba20345d674f62f/cloud_sql_python_connector-1.20.3.tar.gz", hash = "sha256:4b6f5c376982206fb0e62545c86d23ee49d045f2e71da817a326654cd169149a", size = 44211, upload-time = "2026-05-27T01:38:29.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/ca/626917dd95d17eab155ad5f75360ca668928a0df63cc3259041ffd82ffe5/cloud_sql_python_connector-1.20.3-py3-none-any.whl", hash = "sha256:b4732920b5632be946921fa649a6ddeabfed9447a2b8088d55a6b08919ae3b85", size = 50101, upload-time = "2026-05-27T01:38:27.345Z" }, +] + +[[package]] +name = "cloudevents" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/aa/804bdb5f2f021fcc887eeabfa24bad0ffd4b150f60850ae88faa51d393a5/cloudevents-1.12.0.tar.gz", hash = "sha256:ebd5544ceb58c8378a0787b657a2ae895e929b80a82d6675cba63f0e8c5539e0", size = 34494, upload-time = "2025-06-02T18:58:45.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/b6/4e29b74bb40daa7580310a5ff0df5f121a08ce98340e01a960b668468aab/cloudevents-1.12.0-py3-none-any.whl", hash = "sha256:49196267f5f963d87ae156f93fc0fa32f4af69485f2c8e62e0db8b0b4b8b8921", size = 55762, upload-time = "2025-06-02T18:58:44.013Z" }, +] + +[[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 = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "commitizen" +version = "4.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "charset-normalizer" }, + { name = "colorama" }, + { name = "decli" }, + { name = "deprecated" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "prompt-toolkit" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "termcolor" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/cc/d87b094ef858c67febcd1d8902352c84b42c9ebc8221d6f2e9d553273358/commitizen-4.16.3.tar.gz", hash = "sha256:5cdca4c02715cc770312f4b505c65a6c39024c73ece41b943bccaf81c44436ed", size = 66772, upload-time = "2026-05-30T06:34:21.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/35/c7995b1e66159193dd31ed5628d59acbaf4611811645eedf0fb2d5a91946/commitizen-4.16.3-py3-none-any.whl", hash = "sha256:ce1be39fe98a16725fd0c960daf0f360acac86db7ae8db1e1df8d3541005b5be", size = 88927, upload-time = "2026-05-30T06:34:20.006Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, + { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, + { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, +] + +[[package]] +name = "cssselect" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/2e/cdfd8b01c37cbf4f9482eefd455853a3cf9c995029a46acd31dfaa9c1dd6/cssselect-1.4.0.tar.gz", hash = "sha256:fdaf0a1425e17dfe8c5cf66191d211b357cf7872ae8afc4c6762ddd8ac47fc92", size = 40589, upload-time = "2026-01-29T07:00:26.701Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/0c/7bb51e3acfafd16c48875bf3db03607674df16f5b6ef8d056586af7e2b8b/cssselect-1.4.0-py3-none-any.whl", hash = "sha256:c0ec5c0191c8ee39fcc8afc1540331d8b55b0183478c50e9c8a79d44dbceb1d8", size = 18540, upload-time = "2026-01-29T07:00:24.994Z" }, +] + +[[package]] +name = "cssutils" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "encutils" }, + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/00/7e89107ea389e952eea73b1b90ac6633e15a519c4a518ee90bb93a2f83dc/cssutils-2.15.0.tar.gz", hash = "sha256:e9739237f3915037dacba787c4b58f280e3ec5d9864953e185bf23d40ff7d021", size = 716080, upload-time = "2026-04-27T20:40:35.615Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/bf/5669e08a6f53e0d4b4648a989e77163ad36d4718195319c8c5af08ded654/cssutils-2.15.0-py3-none-any.whl", hash = "sha256:207faa466810a1aef109261673f2458356d0839ddedaebc0ee553376290fb6a9", size = 180638, upload-time = "2026-04-27T20:40:34.178Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "dataframe-image" +version = "0.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "beautifulsoup4" }, + { name = "cssselect" }, + { name = "cssutils" }, + { name = "lxml" }, + { name = "mistune" }, + { name = "nbconvert" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "playwright" }, + { name = "requests" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/69/9f2a3d022752e236c8aac259b7a7ce0544a9de2ef28828cba10cc66a8e05/dataframe_image-0.2.7-py3-none-any.whl", hash = "sha256:168755c7c570468d108cec5442f135be1b62f5a94bc09e0f3d86fb182054a8d6", size = 6663053, upload-time = "2025-01-30T07:19:41.902Z" }, +] + +[[package]] +name = "datetime" +version = "5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/66/e284b9978fede35185e5d18fb3ae855b8f573d8c90a56de5f6d03e8ef99e/DateTime-5.5.tar.gz", hash = "sha256:21ec6331f87a7fcb57bd7c59e8a68bfffe6fcbf5acdbbc7b356d6a9a020191d3", size = 63671, upload-time = "2024-03-21T07:26:50.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/78/8e382b8cb4346119e2e04270b6eb4a01c5ee70b47a8a0244ecdb157204f7/DateTime-5.5-py3-none-any.whl", hash = "sha256:0abf6c51cb4ba7cee775ca46ccc727f3afdde463be28dbbe8803631fefd4a120", size = 52649, upload-time = "2024-03-21T07:26:47.849Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + +[[package]] +name = "decli" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/59/d4ffff1dee2c8f6f2dd8f87010962e60f7b7847504d765c91ede5a466730/decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656", size = 7564, upload-time = "2025-06-01T15:23:41.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989, upload-time = "2025-06-01T15:23:40.228Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/8d/873e9252ea2c0e0c857884e0a2899ec43ade132345df1925ef24cbe64f18/distlib-0.4.2.tar.gz", hash = "sha256:baeb401c90f27acd15c4861ae0847d1e731c27ac3dbf4210643ba61fa1e813db", size = 614914, upload-time = "2026-06-08T16:24:15.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/aa891c893821d4d127292ed66c6940d1d715894bd5a0ce048056bc641773/distlib-0.4.2-py2.py3-none-any.whl", hash = "sha256:ca4cb11e5d746b5ec13c199cbf19ae27a241f89702b54e153a74332955446067", size = 470510, upload-time = "2026-06-08T16:24:13.208Z" }, +] + +[[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 = "dotty-dict" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/ab/88d67f02024700b48cd8232579ad1316aa9df2272c63049c27cc094229d6/dotty_dict-1.3.1.tar.gz", hash = "sha256:4b016e03b8ae265539757a53eba24b9bfda506fb94fbce0bee843c6f05541a15", size = 7699, upload-time = "2022-07-09T18:50:57.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014, upload-time = "2022-07-09T18:50:55.058Z" }, +] + +[[package]] +name = "encutils" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "chardet" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/24/15d0f368875e53fb02bf7475e8a8c9aec36dee5a3dcb23efc77f585f9eb6/encutils-1.0.0.tar.gz", hash = "sha256:38eca5af18cebabd8be43c17f14c9d3fbba83cc5f7ac8e3ab1c86e24c4b2b91a", size = 22831, upload-time = "2026-04-19T16:27:19.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/cb/27d1c167d7b6607316c0c4ec5869b256104eb2c9607f76ef2ffa10806d3e/encutils-1.0.0-py3-none-any.whl", hash = "sha256:605297da19a23d1b2da7d3b9bd75513acc979e9facf03aa7ec7ba04b5f567a79", size = 21231, upload-time = "2026-04-19T16:27:18.778Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "f3-data-models" +version = "1.1.0" +source = { editable = "packages/db-python" } +dependencies = [ + { name = "cloud-sql-python-connector" }, + { name = "pg8000" }, + { name = "psycopg2-binary" }, + { name = "sqlalchemy" }, + { name = "sqlalchemy-bigquery" }, + { name = "sqlalchemy-citext" }, + { name = "sqlmodel" }, +] + +[package.dev-dependencies] +dev = [ + { name = "poethepoet" }, +] + +[package.metadata] +requires-dist = [ + { name = "cloud-sql-python-connector", specifier = ">=1.20.0,<2.0.0" }, + { name = "pg8000", specifier = ">=1.31.5,<2.0.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.10,<3.0.0" }, + { name = "sqlalchemy", specifier = ">=2.0.36,<3.0.0" }, + { name = "sqlalchemy-bigquery", specifier = ">=1.13.0,<2.0.0" }, + { name = "sqlalchemy-citext", specifier = ">=1.8.0,<2.0.0" }, + { name = "sqlmodel", specifier = ">=0.0.22,<0.0.23" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "poethepoet", specifier = ">=0.34.0,<0.35.0" }] + +[[package]] +name = "f3-nation-python-workspace" +version = "0.0.0" +source = { virtual = "." } + +[[package]] +name = "f3-nation-slack-bot" +version = "1.13.0" +source = { editable = "apps/slackbot" } +dependencies = [ + { name = "cloud-sql-python-connector" }, + { name = "cryptography" }, + { name = "datetime" }, + { name = "f3-data-models" }, + { name = "functions-framework" }, + { name = "google-cloud-logging" }, + { name = "pg8000" }, + { name = "pillow" }, + { name = "pillow-heif" }, + { name = "pre-commit" }, + { name = "psycopg2-binary" }, + { name = "python-dotenv" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "sendgrid" }, + { name = "slack-bolt" }, + { name = "sqlalchemy" }, + { name = "sqlalchemy-utils" }, + { name = "watchfiles" }, +] + +[package.dev-dependencies] +dev = [ + { name = "alembic" }, + { name = "commitizen" }, + { name = "dataframe-image" }, + { name = "debugpy" }, + { name = "graphviz" }, + { name = "ipykernel" }, + { name = "kaleido" }, + { name = "matplotlib" }, + { name = "mplcyberpunk" }, + { name = "pandas" }, + { name = "playwright" }, + { name = "python-semantic-release" }, + { name = "sqlalchemy-schemadisplay" }, +] + +[package.metadata] +requires-dist = [ + { name = "cloud-sql-python-connector", specifier = ">=1.20.0,<2.0.0" }, + { name = "cryptography", specifier = ">=46.0.5" }, + { name = "datetime", specifier = ">=5.5,<6.0.0" }, + { name = "f3-data-models", editable = "packages/db-python" }, + { name = "functions-framework", specifier = ">=3.8.1,<4.0.0" }, + { name = "google-cloud-logging", specifier = ">=3.11.0,<4.0.0" }, + { name = "pg8000", specifier = ">=1.31.5,<2.0.0" }, + { name = "pillow", specifier = ">=12.2.0" }, + { name = "pillow-heif", specifier = ">=0.15.0,<0.16.0" }, + { name = "pre-commit", specifier = ">=4.2.0,<5.0.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.9,<3.0.0" }, + { name = "python-dotenv", specifier = ">=1.2.1,<2.0.0" }, + { name = "requests", specifier = ">=2.31.0,<3.0.0" }, + { name = "requests-oauthlib", specifier = ">=1.3.1,<2.0.0" }, + { name = "sendgrid", specifier = ">=6.11.0,<7.0.0" }, + { name = "slack-bolt", specifier = ">=1.18.1,<2.0.0" }, + { name = "sqlalchemy", specifier = ">=2.0.28,<3.0.0" }, + { name = "sqlalchemy-utils", specifier = ">=0.41.1,<0.42.0" }, + { name = "watchfiles", specifier = ">=1.1.1,<2.0.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "alembic", specifier = ">=1.13.0,<2.0.0" }, + { name = "commitizen", specifier = ">=4.13.9,<5.0.0" }, + { name = "dataframe-image", specifier = ">=0.2.7,<0.3.0" }, + { name = "debugpy", specifier = ">=1.8.17,<2.0.0" }, + { name = "graphviz", specifier = ">=0.20.3,<0.21.0" }, + { name = "ipykernel", specifier = ">=6.30.1,<7.0.0" }, + { name = "kaleido", specifier = ">=1.1.0,<2.0.0" }, + { name = "matplotlib", specifier = ">=3.10.7,<4.0.0" }, + { name = "mplcyberpunk", specifier = ">=0.7.6,<0.8.0" }, + { name = "pandas", specifier = ">=2.2.2,<3.0.0" }, + { name = "playwright", specifier = ">=1.44.0,<2.0.0" }, + { name = "python-semantic-release", specifier = ">=10.5.3,<11.0.0" }, + { name = "sqlalchemy-schemadisplay", specifier = ">=2.0,<3.0" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[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 = "functions-framework" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "cloudevents" }, + { name = "flask" }, + { name = "gunicorn", marker = "sys_platform != 'win32'" }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "uvicorn-worker" }, + { name = "watchdog" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/4e/63875081ead5db522d33d05fcc5bafad73944da841755ef8a1dc5d872b80/functions_framework-3.10.1.tar.gz", hash = "sha256:e60174022fc1b293dd0a33f1c6894dabd44852c0d440a51e7defd198e8d05ca5", size = 54148, upload-time = "2026-02-17T20:39:43.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/0e/f2cbbd4eb81646b3e09093b4df903c4df60c4791f2a4c6d41e5f3a56b491/functions_framework-3.10.1-py3-none-any.whl", hash = "sha256:48e7fd752d32dfeb528d1c9bf5d95960b6f0bb392f2a4da689f4d3c7a82c1230", size = 41406, upload-time = "2026-02-17T20:39:41.455Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/22/155cadf1d49272a9cf48f3168c0f3874fa13397297e611a5ea00cd093880/google_api_core-2.31.0.tar.gz", hash = "sha256:2be84ee0f584c48e6bde1b36766e23348b361fb7e55e56135fc76ce1c397f9c2", size = 176492, upload-time = "2026-06-03T14:52:17.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/40/9bdbb60b03a332bd45acb8703da08bbc27d991d35286b62e42acc86d243a/google_api_core-2.31.0-py3-none-any.whl", hash = "sha256:ef79fb3784c71cbac89cbd03301ba0c8fb8ad2aa95d7f9204dd9628f7adf59ab", size = 173102, upload-time = "2026-06-03T14:51:26.729Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/ad/ff781329bbbdc0974a098d996e89c9e1f7024262f9e3eec442fbb9ad1ac6/google_auth-2.53.0.tar.gz", hash = "sha256:e7e6aa16f6bee7b2b264830fd04f08087a1d5a836df516251a5d15327b246c9c", size = 335844, upload-time = "2026-05-15T20:53:07.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/c9/db44165ba7c581268c6d46017ef63339110378305062830104fc7fa144cb/google_auth-2.53.0-py3-none-any.whl", hash = "sha256:6e7449917c599b35126a99ec268ec6880301f2fea41dce198fe8fd83ff642b68", size = 246071, upload-time = "2026-05-15T20:53:05.609Z" }, +] + +[[package]] +name = "google-cloud-appengine-logging" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/b9/fcafc8d2dc68975a65cdff74807547cff9b2a7b00e738d3f5ff0bd112867/google_cloud_appengine_logging-1.10.0.tar.gz", hash = "sha256:b5563e76010a36e6adf1cc489620c29ee4fb3b986b006d237e9a061eb0f0abb7", size = 17744, upload-time = "2026-06-03T14:52:40.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/b3/4eeb9f59c4e7e07e1f08704b6508249eea5760878810014e636026300416/google_cloud_appengine_logging-1.10.0-py3-none-any.whl", hash = "sha256:193675caaf062c41688a3e2c744b73614db82408bc7fb060353b6878d7134492", size = 18143, upload-time = "2026-06-03T14:51:55.174Z" }, +] + +[[package]] +name = "google-cloud-audit-log" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/46/b971191224557091cc865b47d527e61da180e33b9397904bdefdae1dcacd/google_cloud_audit_log-0.6.0.tar.gz", hash = "sha256:4dd343683c0bb31187ebef3426803f13159e950fbea3fe60a864855cfed959b8", size = 44674, upload-time = "2026-06-03T14:52:48.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/99/27c70286bfa3503e43f845578ed5c2ab30c0cc68e525c168286f05f9a51c/google_cloud_audit_log-0.6.0-py3-none-any.whl", hash = "sha256:8c5ecbc341ad3b3daf776981f6d7fd7ab5ff5a29c5dce3172c669b570e0f6717", size = 44853, upload-time = "2026-06-03T14:52:03.775Z" }, +] + +[[package]] +name = "google-cloud-bigquery" +version = "3.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-resumable-media" }, + { name = "packaging" }, + { name = "python-dateutil" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/13/6515c7aab55a4a0cf708ffd309fb9af5bab54c13e32dc22c5acd6497193c/google_cloud_bigquery-3.41.0.tar.gz", hash = "sha256:2217e488b47ed576360c9b2cc07d59d883a54b83167c0ef37f915c26b01a06fe", size = 513434, upload-time = "2026-03-30T22:50:55.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/33/1d3902efadef9194566d499d61507e1f038454e0b55499d2d7f8ab2a4fee/google_cloud_bigquery-3.41.0-py3-none-any.whl", hash = "sha256:2a5b5a737b401cbd824a6e5eac7554100b878668d908e6548836b5d8aaa4dcaa", size = 262343, upload-time = "2026-03-30T22:48:45.444Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/dd/1eef226e470369b26824a505c34482c0b493bc35fe8e0c6b003b5feca21a/google_cloud_core-2.6.0.tar.gz", hash = "sha256:e76149739f90fac1fc6757c09f47eaccb3145b54adbd7759b0f7c4b235f46c83", size = 36001, upload-time = "2026-05-07T08:04:04.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/4a/98da8930ab109c73d9a5d13782a9ebb81ea8c111f6d534a567b71d23e52b/google_cloud_core-2.6.0-py3-none-any.whl", hash = "sha256:6d63ac8e5eca6d9e4319d0a1e2265fadcd7f1049904378caecfa01cf52dd869e", size = 29390, upload-time = "2026-05-07T08:02:34.672Z" }, +] + +[[package]] +name = "google-cloud-logging" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-appengine-logging" }, + { name = "google-cloud-audit-log" }, + { name = "google-cloud-core" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/ba/e749846f13c8d1c6c01eb6317e8b09abc130fe67b5d72081a48d1bf96971/google_cloud_logging-3.16.0.tar.gz", hash = "sha256:08a3076b8f0f724219d6f73b2a242ef69d51e8bce226133aebe41a25f23f5400", size = 293703, upload-time = "2026-06-03T15:28:23.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/d5/91035dd77e0033dfb00d52b2bcad1e4f7408eb931981f86a1584301670a8/google_cloud_logging-3.16.0-py3-none-any.whl", hash = "sha256:9e5bfbdfe7b5315ece00e1703a2ea25fe42ca35e0b4750127b019f50d069b01b", size = 234188, upload-time = "2026-06-03T15:27:37.407Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, +] + +[[package]] +name = "google-resumable-media" +version = "2.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/f8/1ca5781d6be9cb9f73f7d40f4958c4bd1226a60598e3e39e1d6aaf838c4b/google_resumable_media-2.10.0.tar.gz", hash = "sha256:e324bc9d0fdae4c52a08ae90456edc4e71ece858399e1217ac0eb3a51d6bc6ee", size = 2164570, upload-time = "2026-06-03T16:14:26.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/d8/00c6854ac1512bb9eaf13bd3f8f28222f7674947fc510a4ff7616f2efc80/google_resumable_media-2.10.0-py3-none-any.whl", hash = "sha256:88152884bee37b2bf36a0ab81ad8c7fd12212c9803dd981d77c1b35b02d34e7c", size = 81533, upload-time = "2026-06-03T16:13:12.51Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, +] + +[[package]] +name = "graphviz" +version = "0.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/83/5a40d19b8347f017e417710907f824915fba411a9befd092e52746b63e9f/graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d", size = 256455, upload-time = "2024-03-21T07:50:45.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/be/d59db2d1d52697c6adc9eacaf50e8965b6345cc143f671e1ed068818d5cf/graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5", size = 47126, upload-time = "2024-03-21T07:50:43.091Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, + { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, + { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, + { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, + { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" }, + { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, + { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, + { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, + { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, + { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, + { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, + { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, + { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, + { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, + { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, + { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, + { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, + { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" }, +] + +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", extra = ["grpc"] }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706, upload-time = "2026-04-01T01:57:49.813Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675, upload-time = "2026-04-01T01:57:47.69Z" }, +] + +[[package]] +name = "grpcio" +version = "1.81.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/f3/23f47b24f8d8c2028eba501db3acfbb2f592cbb5995eaa6e363a627b74d7/grpcio-1.81.0.tar.gz", hash = "sha256:a5acd7efd3b1fe9b4eb0bcaaa1507eed68a0ad0678b654c3f7b464df9ba9dca5", size = 13032272, upload-time = "2026-06-01T05:56:22.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/d5/896a3aaf07068d707d88b282a04914b872db4d32d3c7e6d88e43a3b911fa/grpcio-1.81.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:57b3b0e73a518fa286959b40c3eddd02703504ca186e8b7b2945954519bd8b2c", size = 6053538, upload-time = "2026-06-01T05:54:58.965Z" }, + { url = "https://files.pythonhosted.org/packages/68/6a/7e3eafa4727cd405ff917605ed2949e2af162f233f5cbdd773723a5fea7d/grpcio-1.81.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8bb1789c94322a13336a2b6c58d9c14d68f8628b6e24205a799c69f5bf8516ce", size = 12053447, upload-time = "2026-06-01T05:55:01.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/79/a4302aa82428de48a922421f522b027a1a727ab4d0926368454aa953d36d/grpcio-1.81.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e4d053900a0d24b75d7521139a3872150301b3d6bde3bed5e12318fb25791e4d", size = 6595872, upload-time = "2026-06-01T05:55:04.946Z" }, + { url = "https://files.pythonhosted.org/packages/b4/1f/7ff2850eaefbecf99af3f624dbb28dd1ad6c5fd4c1d8c26909ed6482673b/grpcio-1.81.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:db217c2e52931719f9937bd12082cd4d7b495b35803d5760686975c285924bf8", size = 7303857, upload-time = "2026-06-01T05:55:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/1f3896a9baae1f2aedf4e99c55291d6fa1f30ad9603d63bc18bda967b53e/grpcio-1.81.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19f201da7b4e5c0559198abe5a97157e726f3abe6e8f5e832d4a50740f6dcc22", size = 6809676, upload-time = "2026-06-01T05:55:09.513Z" }, + { url = "https://files.pythonhosted.org/packages/34/8b/3441983718095208c5d797fd3239882e97ea89a629f41c8df94b4eef4df9/grpcio-1.81.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:275144b0115353339dbb8a6f28a9cf8997b5bf40e37f8f66ac0b0ea57e95b43f", size = 7412654, upload-time = "2026-06-01T05:55:12.777Z" }, + { url = "https://files.pythonhosted.org/packages/3c/98/1eddf07df6e4fe85cf67502a793f7b05468b2dca3d1ef35b972cf5d54468/grpcio-1.81.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5192857589f223e5a98ff0e31f6e551b19040e647d17bfe10116c8a2ce3b8696", size = 8408026, upload-time = "2026-06-01T05:55:15.514Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/3860341e6a1f5347be6ab35c6c0e1e3a8eb59d010388207fd561dcf01a88/grpcio-1.81.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6ff087cb1f563f47b504b4e29e684129fc5ae4863faf3ebca08a327764ee6cb", size = 7849498, upload-time = "2026-06-01T05:55:18.078Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3f/0ea06bd85c701966aa3f8f37314f2ed83520d2b7590f42d643d445d8bc8b/grpcio-1.81.0-cp312-cp312-win32.whl", hash = "sha256:98c6240f563178fc5877bd50e6ff274463e53e1472128f4110742450739659fa", size = 4184161, upload-time = "2026-06-01T05:55:20.127Z" }, + { url = "https://files.pythonhosted.org/packages/39/e3/a7c387406827a86f99ad7838b995bf9b4a182ffe2d2c439ed2873efec952/grpcio-1.81.0-cp312-cp312-win_amd64.whl", hash = "sha256:87e33b7afcfb3585121b5f007d2c52b8c534104d18f556e840d35193ca2a9141", size = 4929958, upload-time = "2026-06-01T05:55:22.736Z" }, + { url = "https://files.pythonhosted.org/packages/f3/29/779ee53c931d0fd55c1d459fde43e485172caa3ac87cbd43d003a13a0185/grpcio-1.81.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:62bbe463c9f0f2ff24e31bd25f8dd8b4bae78900e315915a3195a0ef1471a855", size = 6054973, upload-time = "2026-06-01T05:55:25.043Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b6/7211807926b5a17f8d9a5d47c739a163d6812fefe3e4714e81cf92945ed7/grpcio-1.81.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43c121e135ae44d1559b430db2b2dfad7421cbbe40e1deba506c7dc62b439719", size = 12048662, upload-time = "2026-06-01T05:55:28.453Z" }, + { url = "https://files.pythonhosted.org/packages/64/89/b1b93ef6b34bd20bbaf707fa99133bc9cc302139d5ec6f77a165c7169796/grpcio-1.81.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f345de40ef2e65f63645d53d251824e6070e07804827c5b00ec2e44555f9f901", size = 6599116, upload-time = "2026-06-01T05:55:31.185Z" }, + { url = "https://files.pythonhosted.org/packages/eb/bc/c89f9b9d1c22895715356a1e009554dae66319e97826bb4d30bcda7d29e8/grpcio-1.81.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8c0855a350886f713b9e458e2a10d208009dcaa849f574e39cd6067db1fe1279", size = 7307591, upload-time = "2026-06-01T05:55:33.463Z" }, + { url = "https://files.pythonhosted.org/packages/65/4a/1df2a4cb4a1386e066ab7e4175e34bb884b35ccb60d3621c09c84af6aabb/grpcio-1.81.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a524cd530900bd24511fcb7f2ed144da4ea37711c4b094475d0bceca7a93a170", size = 6811797, upload-time = "2026-06-01T05:55:36.731Z" }, + { url = "https://files.pythonhosted.org/packages/8d/dc/fa189d20601a1be25b08850cfb733879bbb1047b62a8feec3a60e3e1a87b/grpcio-1.81.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e7746ba3e6efc9e2b748eff59470a2b8684d5a9ec607c6580bcaa5be175820bc", size = 7415131, upload-time = "2026-06-01T05:55:39.451Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/5625c48cb48d23c6631b3e5294f88e4c751f22a52591ae78859fab96dca1/grpcio-1.81.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:aaaa4f7f2057d795952e4eacf3f342be8b5b156992f6ac85023c8b98794ebd47", size = 8408398, upload-time = "2026-06-01T05:55:42.219Z" }, + { url = "https://files.pythonhosted.org/packages/75/34/0f8202c6809a46c2b4d69125ef3667c40b1c211f8e19930e5fa1f1197039/grpcio-1.81.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0fba53cb96004b2b7fb758b46b2288cb49d0b658316a4e73f3ef67230616ee65", size = 7844481, upload-time = "2026-06-01T05:55:44.849Z" }, + { url = "https://files.pythonhosted.org/packages/c0/95/c3366b5b5edf4c4adc90f2e29ca16e57965a8e56dc8d2ee89565ba1905bb/grpcio-1.81.0-cp313-cp313-win32.whl", hash = "sha256:c197e2ef75a442528072b29e9755da299110e8610e8bcbb59a6b4cf55384f005", size = 4182777, upload-time = "2026-06-01T05:55:47.459Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a7/932f2f748511a32e641a2aba0d30dded3ed6e8bc330e0924e4d5d86853e6/grpcio-1.81.0-cp313-cp313-win_amd64.whl", hash = "sha256:194eddfacc84d80f50512e9fd4ee851d5f2499f18f299c95aa8fb4748f0537e0", size = 4928085, upload-time = "2026-06-01T05:55:50.158Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1d/28b231333857deb840bc3d182ae087510170ea6d68f21393aeb0fe499530/grpcio-1.81.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:a9351055f52660b58f3d4890ea66188b5134399f82b11aa0c55bd4b99eff5390", size = 6055712, upload-time = "2026-06-01T05:55:52.889Z" }, + { url = "https://files.pythonhosted.org/packages/e8/b8/999c14f9dff0fc47549d2e827cba1343ddc18e1d1bf0d06d2cf628eecbd9/grpcio-1.81.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:300f3337b6425fd16ead9a4f9b2ac25801acb64aa5bc0b99eb69901645b2b1d2", size = 12057189, upload-time = "2026-06-01T05:55:55.952Z" }, + { url = "https://files.pythonhosted.org/packages/1e/3d/1fbde079572562af65351151d840525a13879eb7b481d35b55cd64c6127a/grpcio-1.81.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:97bbd623f7ded558fd4f7cb5a4f600c4d4de65c5dd364c83a5b14b2a10a2d3b5", size = 6608136, upload-time = "2026-06-01T05:55:59.069Z" }, + { url = "https://files.pythonhosted.org/packages/32/89/1f17cb6882abfd8e5a303a25d5d1665abef5a8c499a96198c65a651d1b85/grpcio-1.81.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ff83d889e3ebf6341c8c7864ad8031591ad5ca61599072fc511644d1eb962d2b", size = 7307045, upload-time = "2026-06-01T05:56:02.376Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/f98e91b2e755652e637ea2144318b0229b290062199f761b445fe1fa6015/grpcio-1.81.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c4fe218c5a35e1d87a5a26544237f1fa41dfd9cbd3c856b0810a30061f8b0aaf", size = 6812794, upload-time = "2026-06-01T05:56:05.777Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/77892d715ac41e7ec0ace2a50080ffb64e189188056f607a66fe0014d1ee/grpcio-1.81.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b8b025b6af43ee0ad4a70307025d77bcab5adde7c4597786010d802c203e9fc5", size = 7422767, upload-time = "2026-06-01T05:56:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b8/aa04590c6564714d94954515f15a236e59d4b9b3ad01e615f1b706d7792d/grpcio-1.81.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:3d4e0ce5a40a998cf608c8ba60ecfe18fdf364a9aa193ae4ac3faeecd0e86757", size = 8408551, upload-time = "2026-06-01T05:56:11.283Z" }, + { url = "https://files.pythonhosted.org/packages/43/3d/4f4a3450a1973568910c6909cb74abbf2126f68aefae5976962f9f7ad50d/grpcio-1.81.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa948712c8e5fa40ec250870bda14bc7578e1bb832a8912d9d2a0f720518edbe", size = 7846468, upload-time = "2026-06-01T05:56:14.536Z" }, + { url = "https://files.pythonhosted.org/packages/88/f4/5827fd248221ad3b44161c23ce9b5f4ee405b04fc6da5fd402a9aa87a84a/grpcio-1.81.0-cp314-cp314-win32.whl", hash = "sha256:fbbe81314a9d92156abce8b62c09364eb8bafc0ca2a19919a45ec64b5c6cb664", size = 4264427, upload-time = "2026-06-01T05:56:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/127dc2b246096ad50ef7c8d9b7b31d757787aeb796368bcdd4454e4204c4/grpcio-1.81.0-cp314-cp314-win_amd64.whl", hash = "sha256:b93cee313cae4e113fbb3a0ce1ea5633db6f63cfde2b2dc1d817429026b2a50b", size = 5070848, upload-time = "2026-06-01T05:56:19.735Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.81.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/b6/cdc177114997d15c887fb09ccfd16705c8ceb8b4ca2487902b54a7bfd1af/grpcio_status-1.81.0.tar.gz", hash = "sha256:b6fe9788cfdd1f0f63c0528a1e0bfdb41e8ff0583e920d2d8e8888598c01bb69", size = 13900, upload-time = "2026-06-01T06:00:32.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/b7/5aa346bf1cdecd4ed64b86c10a4d5a089ce3da89145f8328caf0b22b240d/grpcio_status-1.81.0-py3-none-any.whl", hash = "sha256:10eb4c2309db902dc26c1873e80a821bf794be772c10dfd83030f7f59f165fab", size = 14634, upload-time = "2026-06-01T06:00:13.345Z" }, +] + +[[package]] +name = "gunicorn" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" }, +] + +[[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 = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + +[[package]] +name = "ipykernel" +version = "6.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/1d/d5ba6edbfe6fae4c3105bca3a9c889563cc752c7f2de45e333164c7f4846/ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6", size = 167493, upload-time = "2025-10-20T11:42:39.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl", hash = "sha256:abe5386f6ced727a70e0eb0cf1da801fa7c5fa6ff82147747d5a0406cd8c94af", size = 117003, upload-time = "2025-10-20T11:42:37.502Z" }, +] + +[[package]] +name = "ipython" +version = "9.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/23/3a27530575643c8bb7bfc757a28e2e7ef80092afbf59a2bc5716320b6602/ipython-9.14.1.tar.gz", hash = "sha256:f913bf74df06d458e46ced84ca506c23797590d594b236fe60b14df213291e7b", size = 4433457, upload-time = "2026-06-05T08:12:34.921Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/22/58818a63eaf8982b67632b1bc20585c811611b15a8da19d6012323dc76a5/ipython-9.14.1-py3-none-any.whl", hash = "sha256:5d4a9ecaa3b10e6e5f269dd0948bdb58ca9cb851899cd23e07c320d3eb11613c", size = 627770, upload-time = "2026-06-05T08:12:33.045Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + +[[package]] +name = "kaleido" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "choreographer" }, + { name = "logistro" }, + { name = "orjson" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/64/53eac73d31dbfc3310ee2e87bcac1ae7417427f0fbe3dd800eaf676db324/kaleido-1.3.0.tar.gz", hash = "sha256:5e0378a7475e98852773deeb6483dee91f8aa7b364dde7b5f2b3622cb468a3e6", size = 68938, upload-time = "2026-05-04T19:45:28.932Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/b9/a6d8bb7d228940f01885bd9f327ab7f9d366a9be775c4bf366bf9d9477ae/kaleido-1.3.0-py3-none-any.whl", hash = "sha256:52714dfd38e8f2a114831826200c40bb10d0ca0c11d4272f3f48ad499cd8f8ea", size = 55580, upload-time = "2026-05-04T19:45:27.483Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, +] + +[[package]] +name = "logistro" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/90/bfd7a6fab22bdfafe48ed3c4831713cb77b4779d18ade5e248d5dbc0ca22/logistro-2.0.1.tar.gz", hash = "sha256:8446affc82bab2577eb02bfcbcae196ae03129287557287b6a070f70c1985047", size = 8398, upload-time = "2025-11-01T02:41:18.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/6aa79ba3570bddd1bf7e951c6123f806751e58e8cce736bad77b2cf348d7/logistro-2.0.1-py3-none-any.whl", hash = "sha256:06ffa127b9fb4ac8b1972ae6b2a9d7fde57598bf5939cd708f43ec5bba2d31eb", size = 8555, upload-time = "2025-11-01T02:41:17.587Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/13/e2/2e325795566de01d0d7c3bb57d3c370616b2d07b01214e84eec5d3b10963/lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0", size = 8577146, upload-time = "2026-05-18T19:18:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/5630b5e4be7d2e6bee8efe83865c925221103cf0221303b104ce134b01e2/lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840", size = 4623866, upload-time = "2026-05-18T19:18:30.669Z" }, + { url = "https://files.pythonhosted.org/packages/d2/51/3904907c063451cf8d4a5c9fe0cad95fa1f4ec57f4e3884fa0731bd7a305/lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14", size = 4950022, upload-time = "2026-05-18T19:19:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/cd/9c7611a51c37a2830928405817cc5d56a97f64fab83cc3f628748b135749/lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909", size = 5086695, upload-time = "2026-05-18T19:19:34.764Z" }, + { url = "https://files.pythonhosted.org/packages/da/d6/24e3b5906abb0b674ff2ae195bc3ce59708df2bcd17cf17703b2d7dd643a/lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00", size = 5031642, upload-time = "2026-05-18T19:19:37.771Z" }, + { url = "https://files.pythonhosted.org/packages/2d/db/6ec54f99019838bff54785c51da07f189eb4676861c5f2730962b0d8d665/lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955", size = 5647338, upload-time = "2026-05-18T19:19:40.553Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/ef4dcfffd22d27a61805d8ed9f7fb888495bc6aa88648fa07c1eaa5586b6/lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13", size = 5239528, upload-time = "2026-05-18T19:19:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/37fb3f0dff146bdcfa78eec47879273820b2a0bf350ec236ce14bd0b1c26/lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7", size = 5350730, upload-time = "2026-05-18T19:19:46.307Z" }, + { url = "https://files.pythonhosted.org/packages/90/42/43253f168388df4fae1f38c01df36ddb9bee39e2048167b54cdcbae85ea3/lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245", size = 4697530, upload-time = "2026-05-18T19:19:49.889Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a8/c5a8504f81bbdfc8e7094c2c850cdb4ed6777fc4d5ddd9e5ab819f3b0d54/lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5", size = 5250670, upload-time = "2026-05-18T19:19:53.199Z" }, + { url = "https://files.pythonhosted.org/packages/77/b7/c7e76ab18744d75e21f320ebf9ff9d1ceae2b54dd431ea5a64caf26c9672/lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462", size = 5084485, upload-time = "2026-05-18T19:19:08.422Z" }, + { url = "https://files.pythonhosted.org/packages/31/31/b35c53f8ef7b7c31cacd23d3638652fff7bcd1deb6eedb709ab43b685908/lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465", size = 4737635, upload-time = "2026-05-18T19:19:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/d9/06/31f23c813a7fe8e0cb1b175e915b08c9bf4e86d225b210feadbdbe519667/lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a", size = 5670681, upload-time = "2026-05-18T19:19:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bc/ce619bccc89b1fd9ad8a8e1330ee3f3beff9f2ff95b712d7bbcdd6e22fc3/lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590", size = 5238229, upload-time = "2026-05-18T19:19:18.131Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5d/b329acbbedc0b619ebc2be6cf7ee9ed07e80892c88d4dfd612c33805789a/lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb", size = 5264191, upload-time = "2026-05-18T19:19:21.118Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/be36fb1425b30db3c3f9df75fe86343ebffb79e6320bd7f588e25bfeac39/lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603", size = 3657202, upload-time = "2026-05-18T19:17:39.509Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ce/3cf9a827342269f54d405a6202397de63f07c69cbd6ce7d183a3f0cba1e9/lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137", size = 4064497, upload-time = "2026-05-18T19:18:14.662Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3e/1a957bde8f0760039e627f94699f82caa782c9d838d86c3d28245ee67212/lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf", size = 3741991, upload-time = "2026-05-19T19:22:59.111Z" }, + { url = "https://files.pythonhosted.org/packages/78/b2/00ed55b3a2efa4658fb795c38d1090ec9b3e8a6c3683d4441fa517f09c3b/lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee", size = 8827545, upload-time = "2026-05-18T19:18:41.193Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/74573db19baa618d5f266f2407898b087ff6927115b00b71e5fc1b700847/lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c", size = 4735736, upload-time = "2026-05-18T19:18:46.761Z" }, + { url = "https://files.pythonhosted.org/packages/16/02/6f7061f4f95f51e545d48e87647c54791d204a4e881be4156e7a26ba5338/lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef", size = 4970291, upload-time = "2026-05-18T19:19:56.215Z" }, + { url = "https://files.pythonhosted.org/packages/b0/02/55fc057d8283427dea7d6edb102e7a840239c77a64a983d92f62a304c0e9/lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a", size = 5102822, upload-time = "2026-05-18T19:19:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/e4/48/8e1cf78d89d66850121d9255a2a24414c98f775da93b90cf976956c24b14/lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c", size = 5027923, upload-time = "2026-05-18T19:20:01.549Z" }, + { url = "https://files.pythonhosted.org/packages/ed/00/0632a0647612c8af24d26997b3b961397daa9d5b2581444805933629a4cb/lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525", size = 5595843, upload-time = "2026-05-18T19:20:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/bc/86/ab008a7dc360711b66858d61c80a5979a70a09f2aa2b05d9698df80b803d/lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca", size = 5224515, upload-time = "2026-05-18T19:20:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/75/c6/2702ff375e728e34f56d9a45339a9cf7e4427e917f542225242d63a05afa/lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e", size = 5312511, upload-time = "2026-05-18T19:20:09.308Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/a5807c98f87a86f10ef9ffab35516df7c0f0c4b6d5d33e9f608ab9c04a31/lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038", size = 4639206, upload-time = "2026-05-18T19:20:11.704Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e1/8a0a2c35734812395f4da4eaf33748a7e5705bfb2a58b128da764339d5ec/lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e", size = 5232404, upload-time = "2026-05-18T19:20:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0e6a4dd5ad84d01d99aa7bae7cfefd4a760a0e0f8176818241de17d9b6c0/lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072", size = 5083769, upload-time = "2026-05-18T19:19:23.758Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7e/161f33d463f6ffc1c7679104b65086dea120080d49dde4d238f015aaee2f/lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52", size = 4758936, upload-time = "2026-05-18T19:19:27.256Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/2369825e3f6ca99305bf9f7b7085fda91c8b0922a89e54d900974aa3ef85/lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b", size = 5620296, upload-time = "2026-05-18T19:19:29.993Z" }, + { url = "https://files.pythonhosted.org/packages/30/90/d61e383146f74c5ab683947ea14dc7b82778838ab9b95ea73a23b60d0191/lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2", size = 5228598, upload-time = "2026-05-18T19:19:33.523Z" }, + { url = "https://files.pythonhosted.org/packages/76/2d/2dafd8149e94b05bb070690efd5bb2680720681e03ff03fc57d2b70a1105/lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e", size = 5247845, upload-time = "2026-05-18T19:19:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/ce/68/b30e913340c380ddac9580c6e6230991fc37240ec4f64704833e4f3e2769/lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1", size = 3897345, upload-time = "2026-05-18T19:17:33.562Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4e/9eb2af5335545f9fbcd7af57bcf87c6025d31eaa31b14ec184a6c8675328/lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e", size = 4393350, upload-time = "2026-05-18T19:18:10.076Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mistune" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/84/620cc3f7e3adf6f5067e10f4dbae71295d8f9e16d5d3f9ef97c40f2f592c/mistune-3.2.1.tar.gz", hash = "sha256:7c8e5501d38bac1582e067e46c8343f17d57ea1aaa735823f3aba1fd59c88a28", size = 98003, upload-time = "2026-05-03T14:33:22.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/7f/a946aa4f8752b37102b41e64dca18a1976ac705c3a0d1dfe74d820a02552/mistune-3.2.1-py3-none-any.whl", hash = "sha256:78cdb0ba5e938053ccf63651b352508d2efa9411dc8810bfb05f2dc5140c0048", size = 53749, upload-time = "2026-05-03T14:33:20.551Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "mplcyberpunk" +version = "0.7.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/53/9cf3da89c28374b92a2cb9b975fd1031858e0eaee31edd762e3a4619ecb7/mplcyberpunk-0.7.6.tar.gz", hash = "sha256:797cb36b5e1e178e7a3da5f4f215e469ef13935fd8c99d1cd20112e90bba7e22", size = 1605851, upload-time = "2025-02-26T13:36:33.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/8f/14ede5882332b013db2479f32b0678ff4bf4f648c5e8bce20b9f97d646bc/mplcyberpunk-0.7.6-py3-none-any.whl", hash = "sha256:5884f7add690a418695ef2f598257fedef89758c17884448518a307470590b42", size = 6527, upload-time = "2025-02-26T13:36:31.938Z" }, +] + +[[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 = "nbclient" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/c9/94d73e5a01c5b926c3fa2496e97d7a8dc28ed5a77c0b2ed712f1a62e6694/nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895", size = 25288, upload-time = "2026-06-05T07:52:40.115Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.42.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pastel" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pg8000" +version = "1.31.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "scramp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/9a/077ab21e700051e03d8c5232b6bcb9a1a4d4b6242c9a0226df2cfa306414/pg8000-1.31.5.tar.gz", hash = "sha256:46ebb03be52b7a77c03c725c79da2ca281d6e8f59577ca66b17c9009618cae78", size = 118933, upload-time = "2025-09-14T09:16:49.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/07/5fd183858dff4d24840f07fc845f213cd371a19958558607ba22035dadd7/pg8000-1.31.5-py3-none-any.whl", hash = "sha256:0af2c1926b153307639868d2ee5cef6cd3a7d07448e12736989b10e1d491e201", size = 57816, upload-time = "2025-09-14T09:16:47.798Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, +] + +[[package]] +name = "pillow-heif" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/72/b0385fcc17c277af764617e1b60c9dbacca55201dadcea2a8da0ee2c3b78/pillow_heif-0.15.0.tar.gz", hash = "sha256:97a3ad62515fa7945ff0d7e4951ea7375eed2d7dc1cc258f0585709df89517e3", size = 14896461, upload-time = "2024-02-03T12:53:55.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/a0/ff792aecf355e2167f244e0242e05596b6dc8735e9ed77d888791b6a2cab/pillow_heif-0.15.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:6a1c2954cb56cbaccd6aa2b5698794355870e1f93350c2721645dbe009221f99", size = 7024264, upload-time = "2024-02-03T12:52:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/67/04/8aa19ea330d0aac78a0e831e03788b38a85529e1934683fdd2be6bfdb820/pillow_heif-0.15.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:9d2856a76adb9839001b6490dde5ae8f1bc36ff347cdce03d91518174af1efeb", size = 3329672, upload-time = "2024-02-03T12:52:47.713Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ca/e224e8927c6034c04f0ee489637cded3e5cd2d95c89566402818241174a1/pillow_heif-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c24f435a25ce99c50f4a648fb71c84476f672aeb67db1ef62143c445a98bd619", size = 6708321, upload-time = "2024-02-03T12:52:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/94/31/c684bc7c71c33fc44ad0132411c1c257fed51316a43dafd42fda543256dc/pillow_heif-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b6226505fe66a689f30e60766a987c18641d5c8304d3e2ddbff1933cf48f263", size = 7530380, upload-time = "2024-02-03T12:52:51.83Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/2567ed2e9f4c4b6ecc3927f915e5015646af6821c6d4371e4369324b6fa3/pillow_heif-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b4416daccd43dd498fd2282545d612b7052238b2d36f319b581cf31a6dfdf09", size = 7882325, upload-time = "2024-02-03T12:52:53.578Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c0/577bd706e8dcb9eefd14ebd89d332a36fc907931fc08fb081065b8364835/pillow_heif-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3f1cc33d60caa86d383255e0b2b6a802624c3d82a47eb3b3c2655080b5ce1522", size = 8634045, upload-time = "2024-02-03T12:52:55.401Z" }, + { url = "https://files.pythonhosted.org/packages/59/e6/8aa95743df03bb243d865f48ec86d22698700eca44a3145f7dc02238e69b/pillow_heif-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:ddffabd96df3d538830421418f4ee63b0b0c1275b0fdbb9ce6da3649bdec0940", size = 8152030, upload-time = "2024-02-03T12:52:57.494Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "playwright" +version = "1.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" }, + { url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" }, + { url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" }, +] + +[[package]] +name = "poethepoet" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pastel" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/f2/3853d6a9a0dac08aa680895839eeab8ec0ed63db375e1f782e623c9309b6/poethepoet-0.34.0.tar.gz", hash = "sha256:86203acce555bbfe45cb6ccac61ba8b16a5784264484195874da457ddabf5850", size = 64474, upload-time = "2025-04-21T13:38:20.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/d1/61431afe22577083fcb50614bc5e5aa73aa0ab35e3fc2ae49708a59ff70b/poethepoet-0.34.0-py3-none-any.whl", hash = "sha256:c472d6f0fdb341b48d346f4ccd49779840c15b30dfd6bc6347a80d6274b5e34e", size = 85851, upload-time = "2025-04-21T13:38:18.257Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/fd/5b1491d9e4b586d621c54f4c36b888714164b6875f8d6afa3f9072906a51/protobuf-7.35.0.tar.gz", hash = "sha256:a2efd84605f41e559f1881b0912b44099d0a2ac9bf46b3474823f10fb393b0e6", size = 458677, upload-time = "2026-05-19T23:02:29.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ee/93d06e358a4aa32280b00e722d3ea0a1f25fc3cc5778d80581c9cca2c10e/protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda", size = 433225, upload-time = "2026-05-19T23:02:19.884Z" }, + { url = "https://files.pythonhosted.org/packages/8b/39/1c76c2da93f3c507e958e0aecee2391cc44d4625de6c728bbc555195b5a8/protobuf-7.35.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:fcbe42a4ac09d3ec9c987ddfcd956afd0b15f1ff613bd8371bde9405ffd5c8e5", size = 328847, upload-time = "2026-05-19T23:02:22.3Z" }, + { url = "https://files.pythonhosted.org/packages/91/1a/39f7ce90a238c1a987a4d81ec26379e02ca0aff367de68e4a1fa474215b9/protobuf-7.35.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4cbf5cc286130e06a6c9bbefac442431173906dfcc979712183d4adcc01b37ee", size = 344030, upload-time = "2026-05-19T23:02:23.591Z" }, + { url = "https://files.pythonhosted.org/packages/70/5b/6baf9008817964454055ff3fe65f1de0b5f1e26c80c82f7fb108b7cd4ea3/protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011", size = 327130, upload-time = "2026-05-19T23:02:24.637Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e5/e46adb0badc388bfb84877a5f9f026aff63f60e611016cf64dbe77e05446/protobuf-7.35.0-cp310-abi3-win32.whl", hash = "sha256:4c4617b83ade0e279d1d2bfe04025a1adb87f9ed657de038620dc0ff959357f6", size = 428946, upload-time = "2026-05-19T23:02:25.741Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ab/547fbd9e16d879dd13c167478f8ae0a83a428008ca07a5e06acdc23ad473/protobuf-7.35.0-cp310-abi3-win_amd64.whl", hash = "sha256:f05bcadf9a2a6b8dda047007075135fb7d08c73d9177aabc067e1be46881a201", size = 439996, upload-time = "2026-05-19T23:02:26.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0", size = 171659, upload-time = "2026-05-19T23:02:27.842Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, + { url = "https://files.pythonhosted.org/packages/91/bb/4608c96f970f6e0c56572e87027ef4404f709382a3503e9934526d7ba051/psycopg2_binary-2.9.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7c729a73c7b1b84de3582f73cdd27d905121dc2c531f3d9a3c32a3011033b965", size = 3712419, upload-time = "2026-04-20T23:34:58.754Z" }, + { url = "https://files.pythonhosted.org/packages/5e/af/48f76af9d50d61cf390f8cd657b503168b089e2e9298e48465d029fcc713/psycopg2_binary-2.9.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4413d0caef93c5cf50b96863df4c2efe8c269bf2267df353225595e7e15e8df7", size = 3822990, upload-time = "2026-04-20T23:35:00.821Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/aba0f99397cd811d32e06fc0cc781f1f3ce98bc0e729cb423925085d781a/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4dfcf8e45ebb0c663be34a3442f65e17311f3367089cd4e5e3a3e8e62c978777", size = 4578696, upload-time = "2026-04-20T23:35:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/eaa74021ac4e4d5c2f83d82fc6615a63f4fe6c94dc4e94c3990427053f67/psycopg2_binary-2.9.12-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c41321a14dd74aceb6a9a643b9253a334521babfa763fa873e33d89cfa122fb5", size = 4274982, upload-time = "2026-04-20T23:35:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/35/ed/c25deff98bd26187ba48b3b250a3ffc3037c46c5b89362534a15d200e0db/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83946ba43979ebfdc99a3cd0ee775c89f221df026984ba19d46133d8d75d3cd9", size = 5894867, upload-time = "2026-04-20T23:35:07.902Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/8d0e21ca77373c6c9589e5c4528f6e8f0c08c62cafc76fb0bddb7a2cee22/psycopg2_binary-2.9.12-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:411e85815652d13560fbe731878daa5d92378c4995a22302071890ec3397d019", size = 4110578, upload-time = "2026-04-20T23:35:10.149Z" }, + { url = "https://files.pythonhosted.org/packages/00/fc/f481e2435bd8f742d0123309174aae4165160ad3ef17c1b99c3622c241d2/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c8ad4c08e00f7679559eaed7aff1edfffc60c086b976f93972f686384a95e2c", size = 3655816, upload-time = "2026-04-20T23:35:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/b9f46466bdbe9f239c96cde8be33c1aace4842f06013b47b730dc9759187/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:00814e40fa23c2b37ef0a1e3c749d89982c73a9cb5046137f0752a22d432e82f", size = 3301307, upload-time = "2026-04-20T23:35:15.029Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/7dc003b32fe35024df89b658104f7c8538a8b2dcbde7a4e746ce929742e7/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:98062447aebc20ed20add1f547a364fd0ef8933640d5372ff1873f8deb9b61be", size = 3048968, upload-time = "2026-04-20T23:35:16.757Z" }, + { url = "https://files.pythonhosted.org/packages/91/58/2dbd7db5c604d45f4950d988506aae672a14126ec22998ced5021cbb76bb/psycopg2_binary-2.9.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:66a7685d7e548f10fb4ce32fb01a7b7f4aa702134de92a292c7bd9e0d3dbd290", size = 3351369, upload-time = "2026-04-20T23:35:18.933Z" }, + { url = "https://files.pythonhosted.org/packages/42/ee/dee8dcaad07f735824de3d6563bc67119fa6c28257b17977a8d624f02fab/psycopg2_binary-2.9.12-cp313-cp313-win_amd64.whl", hash = "sha256:b6937f5fe4e180aeee87de907a2fa982ded6f7f15d7218f78a083e4e1d68f2a0", size = 2757347, upload-time = "2026-04-20T23:35:21.283Z" }, + { url = "https://files.pythonhosted.org/packages/13/1b/708c0dca874acfad6d65314271859899a79007686f3a1f74e82a2ed4b645/psycopg2_binary-2.9.12-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f3b3de8a74ef8db215f22edffb19e32dc6fa41340456de7ec99efdc8a7b3ec2", size = 3712428, upload-time = "2026-04-20T23:35:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/ddbea9d4b4de6aca9431b6ed253f530f8a02d3b8f9bcfd0dbfe2b3de6fe4/psycopg2_binary-2.9.12-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1006fb62f0f0bc5ce256a832356c6262e91be43f5e4eb15b5eaf38079464caf2", size = 3823184, upload-time = "2026-04-20T23:35:25.92Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/bc2fef74b106fa345567122a0659e6d94512ed7dc0131ec44c9e5aba3725/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:840066105706cd2eb29b9a1c2329620056582a4bf3e8169dec5c447042d0869f", size = 4579157, upload-time = "2026-04-20T23:35:28.542Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/d4e3b2005d3de607ca4fbb0e8742e248056e52184a6b94ebda3c1c2c329b/psycopg2_binary-2.9.12-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:863f5d12241ebe1c76a72a04c2113b6dc905f90b9cef0e9be0efd994affd9354", size = 4274970, upload-time = "2026-04-20T23:35:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/2e/42/c9853f8db3967fe08bcde11f53d53b85d351750cae726ce001cb68afa9c1/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a99eaab34a9010f1a086b126de467466620a750634d114d20455f3a824aae033", size = 5895175, upload-time = "2026-04-20T23:35:33.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/b82b5601a97630308bef079f545ffec481bbbc795c2ba5ec416a01d03f60/psycopg2_binary-2.9.12-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd7dc5463ccd61845ac37b7012d0f35a1548df9febe14f8dd549be4a0bc81e", size = 4110658, upload-time = "2026-04-20T23:35:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/62/8c/32ca69b0389ef25dd22937bf9e8fbe2ce27aea20b05ded48c4ce4cb42475/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54a0dfecab1b48731f934e06139dfe11e24219fb6d0ceb32177cf0375f14c7b5", size = 3656251, upload-time = "2026-04-20T23:35:37.854Z" }, + { url = "https://files.pythonhosted.org/packages/c4/29/96992a2b59e3b9d730fcf9612d0a387305025dc867a9fc490a9e496e074e/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:96937c9c5d891f772430f418a7a8b4691a90c3e6b93cf72b5bd7cad8cbca32a5", size = 3301810, upload-time = "2026-04-20T23:35:39.927Z" }, + { url = "https://files.pythonhosted.org/packages/56/ad/44b06659949b243ae10112cd3b20a197f9bf3e81d5651379b9eb889bfaad/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:77b348775efd4cdab410ec6609d81ccecd1139c90265fa583a7255c8064bc03d", size = 3048977, upload-time = "2026-04-20T23:35:41.806Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/10a1bcebadb6aa55e280e1f58975c36a7b560ea525184c7aa4064c466633/psycopg2_binary-2.9.12-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:527e6342b3e44c2f0544f6b8e927d60de7f163f5723b8f1dfa7d2a84298738cd", size = 3351466, upload-time = "2026-04-20T23:35:43.993Z" }, + { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydot" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-gitlab" +version = "6.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "requests-toolbelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/bd/b30f1d3b303cb5d3c72e2d57a847d699e8573cbdfd67ece5f1795e49da1c/python_gitlab-6.5.0.tar.gz", hash = "sha256:97553652d94b02de343e9ca92782239aa2b5f6594c5482331a9490d9d5e8737d", size = 400591, upload-time = "2025-10-17T21:40:02.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/bd/b0d440685fbcafee462bed793a74aea88541887c4c30556a55ac64914b8d/python_gitlab-6.5.0-py3-none-any.whl", hash = "sha256:494e1e8e5edd15286eaf7c286f3a06652688f1ee20a49e2a0218ddc5cc475e32", size = 144419, upload-time = "2025-10-17T21:40:01.233Z" }, +] + +[[package]] +name = "python-http-client" +version = "3.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/fa/284e52a8c6dcbe25671f02d217bf2f85660db940088faf18ae7a05e97313/python_http_client-3.3.7.tar.gz", hash = "sha256:bf841ee45262747e00dec7ee9971dfb8c7d83083f5713596488d67739170cea0", size = 9377, upload-time = "2022-03-09T20:23:56.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/31/9b360138f4e4035ee9dac4fe1132b6437bd05751aaf1db2a2d83dc45db5f/python_http_client-3.3.7-py3-none-any.whl", hash = "sha256:ad371d2bbedc6ea15c26179c6222a78bc9308d272435ddf1d5c84f068f249a36", size = 8352, upload-time = "2022-03-09T20:23:54.862Z" }, +] + +[[package]] +name = "python-semantic-release" +version = "10.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "click-option-group" }, + { name = "deprecated" }, + { name = "dotty-dict" }, + { name = "gitpython" }, + { name = "importlib-resources" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "python-gitlab" }, + { name = "requests" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/3a/7332b822825ed0e902c6e950e0d1e90e8f666fd12eb27855d1c8b6677eff/python_semantic_release-10.5.3.tar.gz", hash = "sha256:de4da78635fa666e5774caaca2be32063cae72431eb75e2ac23b9f2dfd190785", size = 618034, upload-time = "2025-12-14T22:37:29.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/01/ada29a1215df601bded0a2efd3b6d53864a0a9e0a9ea52aeaebe14fd03fd/python_semantic_release-10.5.3-py3-none-any.whl", hash = "sha256:1be0e07c36fa1f1ec9da4f438c1f6bbd7bc10eb0d6ac0089b0643103708c2823", size = 152716, upload-time = "2025-12-14T22:37:28.089Z" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/52/531ef197b426646f26b53815a7d2a67cb7a331ef098bb276db26a68ac49f/requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a", size = 52027, upload-time = "2022-01-29T18:52:24.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/bb/5deac77a9af870143c684ab46a7934038a53eb4aa975bc0687ed6ca2c610/requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", size = 23892, upload-time = "2022-01-29T18:52:22.279Z" }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "rich" +version = "14.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, +] + +[[package]] +name = "scramp" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asn1crypto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/52/a866f1ac9ae9025ec7f9bea803bba9d54796f8a84236165a700831f61b27/scramp-1.4.8.tar.gz", hash = "sha256:bd018fabfe46343cceeb9f1c3e8d23f55770271e777e3accbfaee3ff0a316e71", size = 16630, upload-time = "2026-01-06T21:01:01.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/a962d2477331abfdb2c6a8251b65c673dbb07ad707d1882d61562b8b9147/scramp-1.4.8-py3-none-any.whl", hash = "sha256:87c2f15976845a2872fe5490a06097f0d01813cceb53774ea168c911f2ad025c", size = 13121, upload-time = "2026-01-06T21:00:59.474Z" }, +] + +[[package]] +name = "sendgrid" +version = "6.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "python-http-client" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/fa/f718b2b953f99c1f0085811598ac7e31ccbd4229a81ec2a5290be868187a/sendgrid-6.12.5.tar.gz", hash = "sha256:ea9aae30cd55c332e266bccd11185159482edfc07c149b6cd15cf08869fabdb7", size = 50310, upload-time = "2025-09-19T06:23:09.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/55/b3c3880a77082e8f7374954e0074aafafaa9bc78bdf9c8f5a92c2e7afc6a/sendgrid-6.12.5-py3-none-any.whl", hash = "sha256:96f92cc91634bf552fdb766b904bbb53968018da7ae41fdac4d1090dc0311ca8", size = 102173, upload-time = "2025-09-19T06:23:07.93Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "simplejson" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/2a/54837395a3487c725669428d513293612a48d82b95a0642c936932e5d898/simplejson-4.1.1.tar.gz", hash = "sha256:c08eb9f7a90f77ae470e19a07472e9a79ebc0d1c2315d86a72767665bd5ba79f", size = 118860, upload-time = "2026-04-24T19:24:59.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/25/e90998fe8e480eb43b966c09e835379887d427567ebd496563d3b1e16b19/simplejson-4.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:19040a17154dc03d289bab68d73ce0a6a0be01de30c584bbdd93490bead14b22", size = 112414, upload-time = "2026-04-24T19:23:06.084Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a0/abd4785f36c3400f1fbb21f517be39295a750a714f04b7ee175adf6ef580/simplejson-4.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a94ebaecdbaa80d9551a3ec6bf0c9302fc8b53ab6c1b2bfd498a1df4cb28158d", size = 91120, upload-time = "2026-04-24T19:23:07.877Z" }, + { url = "https://files.pythonhosted.org/packages/b8/78/fc060d2e3b13c6ec59288574b8efac64075e316b2afba4396a56b2422f78/simplejson-4.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:67341c95c0a168ab4a6d1e807e50463f1c8da932c3286d81e201266c427061fa", size = 91055, upload-time = "2026-04-24T19:23:09.264Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b6/156a8de1e1b47694f0e7de6675866936608d45dc68388fd017d36f8693be/simplejson-4.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45ec18e337fec538b7e902d489505c450b2454653d1290f3f50385e6fd8aa607", size = 190297, upload-time = "2026-04-24T19:23:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/86/1c/e4d0eab695be3eb21d0f46bce820752031f03e7113f9c80a9b3c73ee7157/simplejson-4.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:820c69a4710400e9b248d5670647d60be58824369282d3925e516b3ff1a7cd82", size = 187002, upload-time = "2026-04-24T19:23:12.982Z" }, + { url = "https://files.pythonhosted.org/packages/76/0e/7f5a59d29426b062d5928fb88b403c3f797129d53be7102f955dbe51aa44/simplejson-4.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e708d373a10e4378ef2d59f8361850c7150fd907ed49efe49bc5492160476d1", size = 195146, upload-time = "2026-04-24T19:23:14.517Z" }, + { url = "https://files.pythonhosted.org/packages/78/18/9943db224dd4d5fa3c090c3e56a94c37b254338c83995ec5680285111c40/simplejson-4.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:980fc33353f81fd12d8c49d44f8c2760d1dc8192285e627c5180d141035b228a", size = 183931, upload-time = "2026-04-24T19:23:16.742Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/9a690da9a766161c06c627d805362cf159f1abe480969372b2897649b955/simplejson-4.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de2ed102fff88dacf543699f53ee3a533cc11539a39baa176b7e09dd783069d6", size = 192228, upload-time = "2026-04-24T19:23:18.33Z" }, + { url = "https://files.pythonhosted.org/packages/05/88/bd8aad36b451ffb0e0a3f721d695a88befa6d1ac7d1e02ae788ca7ff4029/simplejson-4.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785ff8edc0e28bf773a32543a6bbed46351453c997b3f6709c744e3c2f7eabb", size = 187808, upload-time = "2026-04-24T19:23:21.165Z" }, + { url = "https://files.pythonhosted.org/packages/04/ee/14f91db0d1f481533b651dafbf8cd0da088d9817f7af30c68f7f19f9c847/simplejson-4.1.1-cp312-cp312-win32.whl", hash = "sha256:2e0d5ead6d14610467ec356ec1f6b5d8a56aa216abaad8d41c8b873b16cf313f", size = 88512, upload-time = "2026-04-24T19:23:22.764Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c4/90de06b2d8737c68c05ff9274113f854dbf6a5f28b7a955212111672cb57/simplejson-4.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:63a5451f557d6be48a231bae932458655c620902b868170b2f1c8afed496f6b4", size = 90748, upload-time = "2026-04-24T19:23:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/37/a9/47b445eeb559c9593453a0648e0fd6d08e8adff64dd5e5ced66726da8a09/simplejson-4.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dff52fc7af272e84fc21cc5a06c927c823ca6ae00af14f3b0d7707b42775ed98", size = 113160, upload-time = "2026-04-24T19:23:26.033Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/cb72db31523c164dea5dc55b02dad065a40c478856bc7534b279d2b51906/simplejson-4.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:971aed0647ad6e840a3943bec812fcda5f2d26a5497a4981d1fb49aa4f9a396c", size = 91521, upload-time = "2026-04-24T19:23:27.572Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e5/54cb7c50ad5fdc1e0a86b7df4b135c2cbd5c4623605aa94466659098e8da/simplejson-4.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:249e2e220aa6d9b9d936bde84eb7bf79d5b6c5a8273c6e411f8b1635a9073f2d", size = 91407, upload-time = "2026-04-24T19:23:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/21a3ede87f0bf82d6c7bcb90480d50a6490eb974c6ab20881188e440957c/simplejson-4.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e5cdd6a5d52299f345c15ab5678cc4249e24f383f361d986afbc3c7072a6b6b", size = 192451, upload-time = "2026-04-24T19:23:30.56Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/9903edd3102bf0b5984edfcb90c88612330996efa3b4fbf8a971d6e17839/simplejson-4.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642cec364e0676e2d5a73fa4d31d0c7c55886997caa2fde24e8292ca44d32728", size = 189015, upload-time = "2026-04-24T19:23:32.647Z" }, + { url = "https://files.pythonhosted.org/packages/98/cd/33230927a780e1398b857e3944abb914556994d252b1d765ae40d112cb25/simplejson-4.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:76fe296ca1df23d290033f10aaacf534fd1b3e3007e7f9ff8aa68b21413aaa78", size = 196658, upload-time = "2026-04-24T19:23:34.563Z" }, + { url = "https://files.pythonhosted.org/packages/cd/84/2c5a7444eb53e9a86d3738299bffddd9f53aeed799ded2f45368221fdb19/simplejson-4.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f0ad25b7dc4e0fb23858355819f2e994f1a5badcdcde8737eac7921c2f1ed2a", size = 185967, upload-time = "2026-04-24T19:23:36.191Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/454378e06d059cd412a7ed5d87fb6d29fd5b60f13a4d89fc1f764ff434df/simplejson-4.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a59ebd0533f03fd06ff0c42ba0f02d93cbcdd7944922bf3b93911327a95b901f", size = 193940, upload-time = "2026-04-24T19:23:38.151Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d5/a15bf915f623a2c5a079d6e3be8256fdb8ef06f110669493a09b9d6933e0/simplejson-4.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bccbf4419676b517939852e5aeff2af6aee4dc046881c67a1581fa6f1cb01abd", size = 189795, upload-time = "2026-04-24T19:23:40.139Z" }, + { url = "https://files.pythonhosted.org/packages/d2/c9/37212ae7dc4b607f0978c408e8633f05c810884e054c33113184c6c2c8a2/simplejson-4.1.1-cp313-cp313-win32.whl", hash = "sha256:6c845363eb5fd166fb7c72243da38f4fcfde666ede7fdf2cc6fd7762894626f7", size = 88773, upload-time = "2026-04-24T19:23:41.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c7a0a47883a9015b54c9d8a4b62f2aba17bd4335b1787b9b8a0fc2fa6d52/simplejson-4.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:104d8324c34f25b4b90800bc5fa363780cbc3d8496aef061cba7ce1af9162270", size = 90888, upload-time = "2026-04-24T19:23:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/4a118a6a92eb33bb08c8e2fe7ec85cb96f0673491bb2b829930831ee4fbe/simplejson-4.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ed7473602b6625de793b6acba49aa949f144a475f538792067e4cf2fda2071f5", size = 110492, upload-time = "2026-04-24T19:23:44.957Z" }, + { url = "https://files.pythonhosted.org/packages/07/f4/84d160e9fa8cada1e0a9381cae4fa81eecd573577a5b34366d8ced59bdf7/simplejson-4.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:225c9caa324c5b554d009fb9cac22aee7711e71bd96f487938c659af467e828e", size = 90152, upload-time = "2026-04-24T19:23:46.355Z" }, + { url = "https://files.pythonhosted.org/packages/68/31/9a5432c433a7671107182cdc9a20ea78a70f99c4e5334aa54b6d4d0d79ed/simplejson-4.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:95407269340c7f22f09776ea7b717a52cf56cfcf119b5e45f66faa4a26445bea", size = 90115, upload-time = "2026-04-24T19:23:47.743Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/3635cdb13318cb0a328abaa69e2b91251caad39d6779aa308098f341f6cb/simplejson-4.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3851658d642c1184d2023f0e6c9ce44a21eb1629e74e7c84ef956b128841fe12", size = 184036, upload-time = "2026-04-24T19:23:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/149b6ec5393f6849d98c59cadba888b710a8ef4b805ab91e11a566960d40/simplejson-4.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95a3bb0f78e85f4937f99092239f2011ce06f0f2d803df5c299cc05abbeae008", size = 180543, upload-time = "2026-04-24T19:23:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/df/7c/a5d968d0b527a748b667e62bea94309ccbcb1e2b108e8f0cf8547efaa12b/simplejson-4.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbfdaa7c0603f75b7b14b211b7f2be44696d4e26833ad2d91d5c87bf5fb9a920", size = 188725, upload-time = "2026-04-24T19:23:52.995Z" }, + { url = "https://files.pythonhosted.org/packages/db/e3/6a8d11181d587ef00e2db9112357e6832111e56dd56b01b5c11758a1965d/simplejson-4.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e3c584071dced8c21b4689f0254303521daeb9b5bc1f4289755d71fa3cb0d3", size = 177492, upload-time = "2026-04-24T19:23:54.581Z" }, + { url = "https://files.pythonhosted.org/packages/67/e3/8b0eb8b06e8198cfbd1270487da163d0093df05cc4f557350cd65e2f7e79/simplejson-4.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:036a27bd0469b9d79557cbddb392969f876cd7f278cfbd0fba81534927a06575", size = 185281, upload-time = "2026-04-24T19:23:56.13Z" }, + { url = "https://files.pythonhosted.org/packages/dc/5f/64990f07ec9e2cb1a814c674e2e21b5693207f74ac70eb72151b847ea4e6/simplejson-4.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b70bfd2f67f3351baba08aa3ae9233c83f21fd95ae5e6b3d0ecb8c647929112f", size = 181848, upload-time = "2026-04-24T19:23:57.92Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/bbc1bc0447f339f79f99ab8c37f7f037cb2f1f93af75d6a4d553096bb0c3/simplejson-4.1.1-cp314-cp314-win32.whl", hash = "sha256:37233c72ce88d06acb92747347742b3c07871eba6789f060c179c9302dde8efe", size = 88761, upload-time = "2026-04-24T19:23:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/18/72/ec1b5cbdcb140c132e6c7bdf99bd73e4f675439e77126c88f472fcffa09c/simplejson-4.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:cc0442dea71cd9cbf30a0b8b9929ab5aa6c02c0443a3d977351e6ec5bada4388", size = 91018, upload-time = "2026-04-24T19:24:00.85Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/4fa437f68ff72219bac3bf3d050de9c6265691f3a170e16954bd69d7cddd/simplejson-4.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:c996a4d38290c515af347740659ce095b425449c164a5c9fa3977caa6eff5dbe", size = 113919, upload-time = "2026-04-24T19:24:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/59de041d09eb4a9577f7015d7263c32095dfb7fde49717dff62145d89809/simplejson-4.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c65c763fb20d7ca113c1c14dce2fc04a0fc3a57aceff533d6fdac707c7bffb40", size = 91904, upload-time = "2026-04-24T19:24:03.812Z" }, + { url = "https://files.pythonhosted.org/packages/03/8e/46bb345d540f6eb31427d984a4e518cdb182d0621814fee4fee045e8815b/simplejson-4.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0da5c9f57206ee7ef280ff7f1d924937b0a64f9a271a5ef371a2ecdbebba7421", size = 91752, upload-time = "2026-04-24T19:24:05.622Z" }, + { url = "https://files.pythonhosted.org/packages/83/e2/1b2ce97f068835eb3d253c116a4df7a3f436b7bf2fb5ff1ba29287e8b0ec/simplejson-4.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ea3426e786425d10e9e82f8a6eda74a7d6eb10d99165ac3d0d3bbcb65c0ea343", size = 214021, upload-time = "2026-04-24T19:24:07.447Z" }, + { url = "https://files.pythonhosted.org/packages/48/70/d93e556df6a0786298644a7c08304fcbeddc248325f23f38acbebeb21165/simplejson-4.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d75cea7a1025edd7e439b2966b3d977c45b5b899e2adaf422811b3ac702ed9fb", size = 213530, upload-time = "2026-04-24T19:24:09.289Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/c93bf305b9f00d7259e09e713d60e75bd0f7f53da970f716ab90491770e7/simplejson-4.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63c2ada8e58f266491f19eed2eeeb7c25c6141e52f8f9e820f6bb94156cf8dbc", size = 218282, upload-time = "2026-04-24T19:24:10.991Z" }, + { url = "https://files.pythonhosted.org/packages/0c/20/a9b5d2e27ec44b069ee251bd55544fc76929a067107b1050001566ba86f3/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d1fffb56305c5b475ee746cf9e04f97423ba5aaacd292dc1255bd75b1d3b124b", size = 209249, upload-time = "2026-04-24T19:24:12.662Z" }, + { url = "https://files.pythonhosted.org/packages/97/e4/e06ee682ed5df67592181f5ecb062e35878967e27f5b6e087237d4548d95/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a6525ec733f43d0541206cffa64fd2aad5a7ae3eb76566aff49cd4db6382209a", size = 213963, upload-time = "2026-04-24T19:24:14.302Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9f/1e160e4cd8cdbf062bf6a454cdf814dc7a48eb47e566fdb8f80ccb202605/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:861e393260508efa64d8805a8e49c416c3484907e3f146ce966c69552b49b9a3", size = 210474, upload-time = "2026-04-24T19:24:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e6/cecd913df322df5bbe7ebb8ba39e0708e505a165553900da8a7761026d6f/simplejson-4.1.1-cp314-cp314t-win32.whl", hash = "sha256:d083b89d30948a751d3d97476c2ed91e4caaa24a1a1459bdbadb8876242c71fe", size = 91134, upload-time = "2026-04-24T19:24:17.635Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/f540dde99cc1d393bd062ab3b5735b777561a5d8f8a5f2e241164444d77a/simplejson-4.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4cbb299d0528ec0447fe366d8c9641860e28f997a62730690fef905f1f41046e", size = 94467, upload-time = "2026-04-24T19:24:19.109Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6a/8b74c52ffd33dbbde00fe7251fee6a0acdc8cea33f7a43805aed258fb79b/simplejson-4.1.1-py3-none-any.whl", hash = "sha256:2ce92b3748f02423e26d2bfb636fb9d7a8f67c8f5854dcae69d350d123b2eee2", size = 69195, upload-time = "2026-04-24T19:24:57.962Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "slack-bolt" +version = "1.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "slack-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/97/a62dde97e84027b252807f2044bed2edcda2d063a5cb0c535fb2be8d9b5d/slack_bolt-1.28.0.tar.gz", hash = "sha256:bfe367d867e8fb157a057248ebd4ac2d7f43acac6d0700fa31381db1e10f3b0f", size = 130768, upload-time = "2026-04-06T23:24:59.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/a9/697b6a92c728f09d5ef6b8e83dc6c8a87bc6d59499b2933ed067f11b7e30/slack_bolt-1.28.0-py2.py3-none-any.whl", hash = "sha256:738d1ca5e7c7039b6e18103d29267ced6e18c2517053eff18991fdd593acce5c", size = 234819, upload-time = "2026-04-06T23:24:58.278Z" }, +] + +[[package]] +name = "slack-sdk" +version = "3.42.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/00/16258bfa547559b2c936b50c882b4f0a36ebf6b69639eb763d8fa5e8d6cb/slack_sdk-3.42.0.tar.gz", hash = "sha256:873db9e1f632ac650ffdbf9d8ba825f3e9e7e576a1e4f9604ccb2a15b3727e3d", size = 252136, upload-time = "2026-05-18T17:50:44.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/ef/8a1556bd4843443993fc116783790a7cc553601a37f7d965ec26eef95e76/slack_sdk-3.42.0-py2.py3-none-any.whl", hash = "sha256:eb39aff97e476e10cc5a8ac29bd2e79a9959e880d9fe0c03b4e8f05b2ac996ff", size = 315469, upload-time = "2026-05-18T17:50:41.972Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" }, + { url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" }, + { url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" }, + { url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" }, + { url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" }, + { url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" }, +] + +[[package]] +name = "sqlalchemy-bigquery" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-bigquery" }, + { name = "packaging" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/94/6fd01b23a92a2372a71cd1670302a6c11b138ad80906914433e6ddbc1e1a/sqlalchemy_bigquery-1.17.0.tar.gz", hash = "sha256:472284546a0c79cbf99b1bb0f5f99c5131fa888ea25d2d53208e6863e5094e2f", size = 119746, upload-time = "2026-05-07T08:04:51.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/bf/64ae26c6b58665b76abee9f7e536cef0e886c37e1da0b18f75133ff2fa4d/sqlalchemy_bigquery-1.17.0-py3-none-any.whl", hash = "sha256:89c1d4fc9f045ce762c93bf4b73a6c51a203dcf0dbe2d9ade540c7c5e3ed01dd", size = 39802, upload-time = "2026-05-07T08:03:33.787Z" }, +] + +[[package]] +name = "sqlalchemy-citext" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/c3/404caceaffdf0dcfa822e8b068aebc4fef328bf85af42b6cd8fdd2b2555b/sqlalchemy-citext-1.8.0.tar.gz", hash = "sha256:a1740e693a9a334e7c8f60ae731083fe75ce6c1605bb9ca6644a6f1f63b15b77", size = 3601, upload-time = "2021-03-02T18:14:03.539Z" } + +[[package]] +name = "sqlalchemy-schemadisplay" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "pydot" }, + { name = "setuptools" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/b0/d4587a6223dd563072ed5d0b94e0d062bb3d019bf16d0e65a85324c49efc/sqlalchemy_schemadisplay-2.0.tar.gz", hash = "sha256:e90b9c9868814975d674a889aadb7c4651658f0e119e1c9320279ea527744d5e", size = 11637, upload-time = "2024-02-15T11:52:53.355Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/9e/a8d5cea8fb842393846ad42596eeb989511fab0aa1c88fe226c24c6355e7/sqlalchemy_schemadisplay-2.0-py3-none-any.whl", hash = "sha256:e4b928e2aec145f72a2b35de7855a78fca5e09ac4d48f2d58b4472cb640cd362", size = 11377, upload-time = "2024-02-15T11:52:52.21Z" }, +] + +[[package]] +name = "sqlalchemy-utils" +version = "0.41.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bf/abfd5474cdd89ddd36dbbde9c6efba16bfa7f5448913eba946fed14729da/SQLAlchemy-Utils-0.41.2.tar.gz", hash = "sha256:bc599c8c3b3319e53ce6c5c3c471120bd325d0071fb6f38a10e924e3d07b9990", size = 138017, upload-time = "2024-03-24T15:17:28.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/f0/dc4757b83ac1ab853cf222df8535ed73973e0c203d983982ba7b8bc60508/SQLAlchemy_Utils-0.41.2-py3-none-any.whl", hash = "sha256:85cf3842da2bf060760f955f8467b87983fb2e30f1764fd0e24a48307dc8ec6e", size = 93083, upload-time = "2024-03-24T15:17:24.533Z" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/39/8641040ab0d5e1d8a1c2325ae89a01ae659fc96c61a43d158fb71c9a0bf0/sqlmodel-0.0.22.tar.gz", hash = "sha256:7d37c882a30c43464d143e35e9ecaf945d88035e20117bf5ec2834a23cbe505e", size = 116392, upload-time = "2024-08-31T09:43:24.088Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b1/3af5104b716c420e40a6ea1b09886cae3a1b9f4538343875f637755cae5b/sqlmodel-0.0.22-py3-none-any.whl", hash = "sha256:a1ed13e28a1f4057cbf4ff6cdb4fc09e85702621d3259ba17b3c230bfb2f941b", size = 28276, upload-time = "2024-08-31T09:43:22.358Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + +[[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 = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + +[[package]] +name = "uvicorn-worker" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gunicorn" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/25/09cd7a90c8bb7fb693be0d6704fccd5f9778d5513214b7a01cc4a94ff314/uvicorn_worker-0.4.0-py3-none-any.whl", hash = "sha256:e2ed952cef976f5e9e429d7269640bbcafbd36c80aa80f1003c8c77a6797abde", size = 5364, upload-time = "2025-09-20T10:46:59.776Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, + { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, + { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, + { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, + { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, + { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, + { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, + { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, + { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, + { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, + { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/dc/50550cfcbb2ea3cbca5f1d7ed05c8aa840f831a0f2d63aec0a953f7c590e/zope_interface-8.5.tar.gz", hash = "sha256:7a3ba1c5877f0f3e3906b02ddf793abed2becc2948116414ce0e1dd820b68d6d", size = 257957, upload-time = "2026-05-26T06:50:14.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/cc/b84123a948f3162a34623e188922827cd845244fdd043ed20f8d02228caa/zope_interface-8.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8e6ee90c2e6de7c37058d5fa41f123c8b13a312db8d1e0fb5840d7f4bcdff9c9", size = 212165, upload-time = "2026-05-26T06:49:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/cbceec44f1b27208a76c1a688c131302685852406a23df5aab68324109cc/zope_interface-8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1adc90d3576b3b4c4de4953e6002c37bef28b78d7fa54c1bbfd0c50f022fe7c", size = 212341, upload-time = "2026-05-26T06:49:28.182Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c3/005032195ff3b210c139b7c560ed5c534e844b0907d8e44d2b3d8919305e/zope_interface-8.5-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e6347b8d8d12c5eca6502450a92be30079b7acfade2c4f693efa0deb8871b06e", size = 265296, upload-time = "2026-05-26T06:49:29.741Z" }, + { url = "https://files.pythonhosted.org/packages/c5/66/1036543d6a66bc04c19df3cf650f3ad938a002ab0a443c24e23e8de5e8b9/zope_interface-8.5-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5e970dabea777a24b0b0bbf9dae3ab75ce8b2d8e948edf4875627034b21f3560", size = 270689, upload-time = "2026-05-26T06:49:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/30/4c/8b56259558cace4414e753ca6740396a1f59d4a95ddb55b4658600408670/zope_interface-8.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0b48ccadaa9839e09ff81e969703cecb3f402c813bfe8b958652e699bea69f5", size = 270280, upload-time = "2026-05-26T06:49:33.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ea/649908c83aa8fdb7faf2ddca4d3cf6fb8f2157121267dc56e8f72681e26c/zope_interface-8.5-cp312-cp312-win_amd64.whl", hash = "sha256:e0e311f1277468c08fd59a2b41f71b43d25dff639789d364747acd1705c0df6e", size = 215019, upload-time = "2026-05-26T06:49:35.607Z" }, + { url = "https://files.pythonhosted.org/packages/9f/97/da13037b4c563e4df32eedbc819f8c00b754af494f68211e3dffd48d52da/zope_interface-8.5-cp312-cp312-win_arm64.whl", hash = "sha256:652b73107a04159ec6c020db6c1543d4f1e8f4d069bd2aac88a947820923517b", size = 213569, upload-time = "2026-05-26T06:49:37.317Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/4c15755d701f2ec0e80d64a18e1ebaf5be2c584c0ec153fd516f5d13eada/zope_interface-8.5-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:28e80457c134d1fa57a7d758004dece348654e1b1467ac22dcdc20fc1d127c52", size = 212512, upload-time = "2026-05-26T06:49:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/4360c54c465db042cc8fbeeec92abac28b4cedbf6ba63c1f092fd08a190f/zope_interface-8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09495ce9d559c06b70f2d4855b3e4f48a822a9ddc8be1d30c5b4e5be14ae1ace", size = 212541, upload-time = "2026-05-26T06:49:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a5/692a2b8d70f78e848793231d5fae5fecbf8d0cccd73430fdc34802a6d3c1/zope_interface-8.5-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7849ad8fa90763cc1087f4dda78ca3a233e950b3e08fac7079297c9cafbbd7bb", size = 265191, upload-time = "2026-05-26T06:49:43.449Z" }, + { url = "https://files.pythonhosted.org/packages/70/8d/454a9cfc7a050c394ab4f11b3371f7897828b7415e096afff724637e65e0/zope_interface-8.5-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5578c9421ca409a1f39f153d6f7803e4cde01da592ec75a9ac5e1b777d18d33b", size = 270626, upload-time = "2026-05-26T06:49:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/db8409cfa3575b8e9b4800babd7d49f8228433cd1f0c56814bd0ada49c33/zope_interface-8.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e1bd7d96b4ca5fa311f54c9eac16dce4886b428c1531dbe06067763ccdf123b4", size = 270444, upload-time = "2026-05-26T06:49:47.025Z" }, + { url = "https://files.pythonhosted.org/packages/4a/df/a386940e41469ef615e100a216d8b386521e9e598817147f87932ca203c4/zope_interface-8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0c8123d2a4dfde2a613c7cb772605477724782c20bc2e0ad1d9435376a6a44a3", size = 215021, upload-time = "2026-05-26T06:49:48.478Z" }, + { url = "https://files.pythonhosted.org/packages/89/75/477eb5669b6b2a7a843decd1a075e9b1971a8720017654143a7183abd3d9/zope_interface-8.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d02be14f3173c6c7288bc2fdf530090c01c3cf8764ad46c68024686f364278e", size = 213610, upload-time = "2026-05-26T06:49:50.01Z" }, + { url = "https://files.pythonhosted.org/packages/d4/19/5032e954827fdf02db2d2f49737ac4378bb9cfc2cd95a8f2e2a5ae2ec01a/zope_interface-8.5-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:ffaecf013251a89d0de6feb49a46eba48ad8cbbf8a40aeb6045e459e7bec6784", size = 212597, upload-time = "2026-05-26T06:49:51.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/53/3ef644012cf8a6a234a2d6134aab5a5c65ac5467c86296865501d4fbc406/zope_interface-8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:126fa9d1c52295ae076d4cf968634f0a1826afa408a20808b57ff72877b8f69f", size = 212626, upload-time = "2026-05-26T06:49:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/bc8b4f465d388039255003e230c284a175cedf1203c692f23cb7bff64efe/zope_interface-8.5-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:3090e3a663d20194756a59a272e0c8508b889341e31d5894223331fe6b4f9b21", size = 266827, upload-time = "2026-05-26T06:49:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/a7/eb/37d05b935ede53d79690fecc8d201440084418e590bcfc05f384451c7593/zope_interface-8.5-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9342fb74e2afefdb081bf1df727d209ea56995c6e13f5a0540e6d7aff4beafb8", size = 270139, upload-time = "2026-05-26T06:49:57.116Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/fd0c54579e2ce8dc6cf1a757903f3374bc6fbda929a46af9e0f53cb0e5f0/zope_interface-8.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c54725d818f1b57a7efb8b16528326e1f3c257b602b32393fd255c45af8799d", size = 270338, upload-time = "2026-05-26T06:49:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1d/c420dcd777bb761067ea92879ac766694a5ca78608185f1aecea64cbfc11/zope_interface-8.5-cp314-cp314-win_amd64.whl", hash = "sha256:29d74febbae1afeb6834c4ccbf42e242a673c860060f09e53142825270456140", size = 215789, upload-time = "2026-05-26T06:50:00.405Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/50b5eb8f94e527edceac14f9955e58917424ea79bb572ddc18548561cbc2/zope_interface-8.5-cp314-cp314-win_arm64.whl", hash = "sha256:633c8c49396f38df030340797c533e9fe460d1b5d1e42d88e55e938e525f548c", size = 213757, upload-time = "2026-05-26T06:50:01.973Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/5d5f32c4dfcdb16ce2ec5363da686840f13c13e1a1214cb70b49e1cd6d9f/zope_interface-8.5-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:133999820fdbae513c36c03d6f29ef87317aaa3edef39112222b155083664714", size = 213591, upload-time = "2026-05-26T06:50:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/de0c3459ff717fce3342f9a29464c281fdeb0d36c3171ee88d119d5f0650/zope_interface-8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8bd75c96966e573232f0599deaff717564828031c7f05563ccc1ac35c5ee0304", size = 213733, upload-time = "2026-05-26T06:50:05.101Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/d97430abd5ae9677e8b9295b58720c0064a5b557dbb6b8bf5928484cf0d8/zope_interface-8.5-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:14b0e9799351d4c34fe99afd67f0cdd76e55ba15c66a98699d5fc22ea8241e08", size = 294905, upload-time = "2026-05-26T06:50:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/41/ec/a0f8f3dad6e74992f4654bdd94802be0929eabca7b871cac3b6fbb5e961b/zope_interface-8.5-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cd6a732ac84b94eb1ef9222a117347a27efd294ee16810ffdf7ecd307677ed5", size = 300885, upload-time = "2026-05-26T06:50:08.997Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/6881b48803a0ee8d23eb5efa30fce3ed218a2bd9de5758ce489d224fee81/zope_interface-8.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:798b7c87d0e59a7d5d086d642208d0d8700ff0d55c4029134b3c479c3bfb110f", size = 304672, upload-time = "2026-05-26T06:50:10.563Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0e/b4c01320859ff1d585438bc231fd60bd258d096359bccf6654fecdf0cffb/zope_interface-8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc3a9d45f114d27eaa1e53beeb144533689edca8a9f66505b1e8e8b3f075e42", size = 217241, upload-time = "2026-05-26T06:50:12.171Z" }, +] From 186b722ab096fcc14ee936ea539ddb1acdb468ac Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Thu, 11 Jun 2026 08:34:25 -0500 Subject: [PATCH 02/16] fix(slackbot): added pytest to deps and fixed some failing tests --- .../features/calendar/event_instance.py | 3 +- apps/slackbot/pyproject.toml | 1 + .../slackbot/tests/features/test_positions.py | 16 +++++---- uv.lock | 36 +++++++++++++++++++ 4 files changed, 49 insertions(+), 7 deletions(-) diff --git a/apps/slackbot/features/calendar/event_instance.py b/apps/slackbot/features/calendar/event_instance.py index 623ba039..9a259111 100644 --- a/apps/slackbot/features/calendar/event_instance.py +++ b/apps/slackbot/features/calendar/event_instance.py @@ -531,7 +531,8 @@ def handle_event_instance_edit_delete( user_id = safe_get(body, "user", "id") or safe_get(body, "user_id") section_block_id = safe_get(body, "actions", 0, "block_id") - section_block = next((b for b in safe_get(body, "view", "blocks") if b["block_id"] == section_block_id), None) + view_blocks = safe_get(body, "view", "blocks") or [] + section_block = next((b for b in view_blocks if b.get("block_id") == section_block_id), None) event_title = safe_get(section_block, "text", "text") or "an event" service = _build_event_instance_service() diff --git a/apps/slackbot/pyproject.toml b/apps/slackbot/pyproject.toml index 66e2ac6f..09378f4c 100644 --- a/apps/slackbot/pyproject.toml +++ b/apps/slackbot/pyproject.toml @@ -57,6 +57,7 @@ dependencies = [ [dependency-groups] dev = [ + "pytest>=8.4.2,<9.0.0", "sqlalchemy-schemadisplay>=2.0,<3.0", "graphviz>=0.20.3,<0.21.0", "ipykernel>=6.30.1,<7.0.0", diff --git a/apps/slackbot/tests/features/test_positions.py b/apps/slackbot/tests/features/test_positions.py index 53ab2f66..c4df7a2f 100644 --- a/apps/slackbot/tests/features/test_positions.py +++ b/apps/slackbot/tests/features/test_positions.py @@ -202,16 +202,18 @@ def test_build_position_list_modal_shows_positions(self): class BuildConfigSltFormTest(unittest.TestCase): @patch("features.positions._build_position_service") - @patch("features.positions.DbManager") + @patch("features.positions._build_ao_service") @patch("features.positions._user_id_to_slack_id_map", return_value={}) @patch("features.positions.PositionViews.build_slt_modal") def test_build_config_slt_form_posts_modal( - self, mock_build_modal, mock_uid_map, mock_dbmanager, mock_build_service + self, mock_build_modal, mock_uid_map, mock_build_ao_service, mock_build_service ): mock_service = MagicMock() mock_build_service.return_value = mock_service mock_service.get_positions_with_assignments.return_value = [] - mock_dbmanager.find_records.return_value = [] + mock_ao_service = MagicMock() + mock_build_ao_service.return_value = mock_ao_service + mock_ao_service.get_region_aos.return_value = [] mock_build_modal.return_value = MagicMock() body = {"trigger_id": "T1"} @@ -224,16 +226,18 @@ def test_build_config_slt_form_posts_modal( mock_build_modal.return_value.post_modal.assert_called_once() @patch("features.positions._build_position_service") - @patch("features.positions.DbManager") + @patch("features.positions._build_ao_service") @patch("features.positions._user_id_to_slack_id_map", return_value={}) @patch("features.positions.PositionViews.build_slt_modal") def test_build_config_slt_form_updates_on_level_change( - self, mock_build_modal, mock_uid_map, mock_dbmanager, mock_build_service + self, mock_build_modal, mock_uid_map, mock_build_ao_service, mock_build_service ): mock_service = MagicMock() mock_build_service.return_value = mock_service mock_service.get_positions_with_assignments.return_value = [] - mock_dbmanager.find_records.return_value = [] + mock_ao_service = MagicMock() + mock_build_ao_service.return_value = mock_ao_service + mock_ao_service.get_region_aos.return_value = [] mock_build_modal.return_value = MagicMock() body = { diff --git a/uv.lock b/uv.lock index 3dabda18..f2ec9fec 100644 --- a/uv.lock +++ b/uv.lock @@ -934,6 +934,7 @@ dev = [ { name = "mplcyberpunk" }, { name = "pandas" }, { name = "playwright" }, + { name = "pytest" }, { name = "python-semantic-release" }, { name = "sqlalchemy-schemadisplay" }, ] @@ -974,6 +975,7 @@ dev = [ { name = "mplcyberpunk", specifier = ">=0.7.6,<0.8.0" }, { name = "pandas", specifier = ">=2.2.2,<3.0.0" }, { name = "playwright", specifier = ">=1.44.0,<2.0.0" }, + { name = "pytest", specifier = ">=8.4.2,<9.0.0" }, { name = "python-semantic-release", specifier = ">=10.5.3,<11.0.0" }, { name = "sqlalchemy-schemadisplay", specifier = ">=2.0,<3.0" }, ] @@ -1548,6 +1550,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "ipykernel" version = "6.31.0" @@ -2618,6 +2629,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "poethepoet" version = "0.34.0" @@ -3029,6 +3049,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 348103f9a2c1004190e1ad651a00c5f7339e2aa7 Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Fri, 12 Jun 2026 06:41:52 -0500 Subject: [PATCH 03/16] refactor(slackbot): removed python semantic release in favor of release-please --- apps/slackbot/.github/workflows/release.yml | 44 --------------------- apps/slackbot/pyproject.toml | 19 --------- apps/slackbot/scripts/requirements.txt | 1 - uv.lock | 26 ------------ 4 files changed, 90 deletions(-) delete mode 100644 apps/slackbot/.github/workflows/release.yml diff --git a/apps/slackbot/.github/workflows/release.yml b/apps/slackbot/.github/workflows/release.yml deleted file mode 100644 index ee18ca89..00000000 --- a/apps/slackbot/.github/workflows/release.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Release and Sync - -on: - push: - branches: - - prod - -jobs: - release: - runs-on: ubuntu-latest - concurrency: release - permissions: - contents: write - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - token: ${{ secrets.RELEASE_TOKEN }} # See Note below - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install Poetry - run: | - curl -sSL https://install.python-poetry.org | python3 - - echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Python Semantic Release - uses: python-semantic-release/python-semantic-release@master - with: - github_token: ${{ secrets.RELEASE_TOKEN }} - - - name: Sync Prod back to Main - # Only run this if a new release was actually created - run: | - git config user.name "github-actions" - git config user.email "github-actions@github.com" - git fetch origin main - git checkout main - git merge prod --no-edit -m "chore: sync prod to main [skip ci]" - git push origin main diff --git a/apps/slackbot/pyproject.toml b/apps/slackbot/pyproject.toml index 09378f4c..0dae6847 100644 --- a/apps/slackbot/pyproject.toml +++ b/apps/slackbot/pyproject.toml @@ -70,31 +70,12 @@ dev = [ "alembic>=1.13.0,<2.0.0", "debugpy>=1.8.17,<2.0.0", "commitizen>=4.13.9,<5.0.0", - "python-semantic-release>=10.5.3,<11.0.0", ] [build-system] requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" -[tool.semantic_release] -version_toml = ["pyproject.toml:project.version"] - -upload_to_release = false -upload_to_pypi = false - -[tool.semantic_release.branches.main] -match = "prod" -prerelease = false - -[tool.semantic_release.changelog] -changelog_file = "CHANGELOG.md" - -[tool.semantic_release.commit_parser_options] -# These tags define what triggers a version bump -minor_tags = ["feat"] -patch_tags = ["fix", "perf"] - [tool.setuptools] include-package-data = true diff --git a/apps/slackbot/scripts/requirements.txt b/apps/slackbot/scripts/requirements.txt index 38fe4da4..fde267d0 100644 --- a/apps/slackbot/scripts/requirements.txt +++ b/apps/slackbot/scripts/requirements.txt @@ -140,7 +140,6 @@ python-discovery==1.3.1 ; python_version >= "3.12" and python_version < "4.0" python-dotenv==1.2.2 ; python_version >= "3.12" and python_version < "4.0" python-gitlab==6.5.0 ; python_version >= "3.12" and python_version < "4.0" python-http-client==3.3.7 ; python_version >= "3.12" and python_version < "4.0" -python-semantic-release==10.5.3 ; python_version >= "3.12" and python_version < "4.0" pytz==2026.2 ; python_version >= "3.12" and python_version < "4.0" pyyaml==6.0.3 ; python_version >= "3.12" and python_version < "4.0" pyzmq==27.1.0 ; python_version >= "3.12" and python_version < "4.0" diff --git a/uv.lock b/uv.lock index f2ec9fec..23564d67 100644 --- a/uv.lock +++ b/uv.lock @@ -935,7 +935,6 @@ dev = [ { name = "pandas" }, { name = "playwright" }, { name = "pytest" }, - { name = "python-semantic-release" }, { name = "sqlalchemy-schemadisplay" }, ] @@ -976,7 +975,6 @@ dev = [ { name = "pandas", specifier = ">=2.2.2,<3.0.0" }, { name = "playwright", specifier = ">=1.44.0,<2.0.0" }, { name = "pytest", specifier = ">=8.4.2,<9.0.0" }, - { name = "python-semantic-release", specifier = ">=10.5.3,<11.0.0" }, { name = "sqlalchemy-schemadisplay", specifier = ">=2.0,<3.0" }, ] @@ -3121,30 +3119,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/31/9b360138f4e4035ee9dac4fe1132b6437bd05751aaf1db2a2d83dc45db5f/python_http_client-3.3.7-py3-none-any.whl", hash = "sha256:ad371d2bbedc6ea15c26179c6222a78bc9308d272435ddf1d5c84f068f249a36", size = 8352, upload-time = "2022-03-09T20:23:54.862Z" }, ] -[[package]] -name = "python-semantic-release" -version = "10.5.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "click-option-group" }, - { name = "deprecated" }, - { name = "dotty-dict" }, - { name = "gitpython" }, - { name = "importlib-resources" }, - { name = "jinja2" }, - { name = "pydantic" }, - { name = "python-gitlab" }, - { name = "requests" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "tomlkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/3a/7332b822825ed0e902c6e950e0d1e90e8f666fd12eb27855d1c8b6677eff/python_semantic_release-10.5.3.tar.gz", hash = "sha256:de4da78635fa666e5774caaca2be32063cae72431eb75e2ac23b9f2dfd190785", size = 618034, upload-time = "2025-12-14T22:37:29.782Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/01/ada29a1215df601bded0a2efd3b6d53864a0a9e0a9ea52aeaebe14fd03fd/python_semantic_release-10.5.3-py3-none-any.whl", hash = "sha256:1be0e07c36fa1f1ec9da4f438c1f6bbd7bc10eb0d6ac0089b0643103708c2823", size = 152716, upload-time = "2025-12-14T22:37:28.089Z" }, -] - [[package]] name = "pytz" version = "2026.2" From 6f57ea90f1739c557d09f267abf07cc99996a6ae Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Fri, 12 Jun 2026 09:40:41 -0500 Subject: [PATCH 04/16] feat(slackbot): starting to add tag-based deployment for slackbot --- .github/workflows/_deploy-cloudrun-job.yml | 228 +++++++++++++++++++++ .github/workflows/_deploy-cloudrun.yml | 14 +- .github/workflows/deploy-slackbot.yml | 50 +++++ apps/slackbot/README.md | 34 +++ apps/slackbot/scripts/cloudbuild.yaml | 3 + docs/RELEASE_PROCESS.md | 18 +- 6 files changed, 338 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/_deploy-cloudrun-job.yml create mode 100644 .github/workflows/deploy-slackbot.yml diff --git a/.github/workflows/_deploy-cloudrun-job.yml b/.github/workflows/_deploy-cloudrun-job.yml new file mode 100644 index 00000000..47df9e8c --- /dev/null +++ b/.github/workflows/_deploy-cloudrun-job.yml @@ -0,0 +1,228 @@ +name: Reusable Cloud Run Job Deploy + +# Reusable pipeline for Cloud Run Jobs (batch/cron workloads). +# ci-gate -> build image once -> deploy staging job -> promote + deploy prod job. + +on: + workflow_call: + inputs: + image_name: + description: "Artifact Registry image name (e.g. f3-bot-scripts)" + required: true + type: string + job_name_staging: + description: "Staging Cloud Run Job name" + required: true + type: string + job_name_prod: + description: "Production Cloud Run Job name" + required: true + type: string + staging_project: + description: "GCP project ID for staging" + required: true + type: string + prod_project: + description: "GCP project ID for production" + required: true + type: string + tag_prefix: + description: "Tag prefix to strip for the image version (e.g. slackbot@)" + required: true + type: string + dockerfile: + description: "Path to the Dockerfile" + required: true + type: string + build_context: + description: "Docker build context path" + required: false + type: string + default: "." + staging_environment: + description: "GitHub Environment name for staging" + required: true + type: string + prod_environment: + description: "GitHub Environment name for production" + required: true + type: string + region: + description: "GCP region" + required: false + type: string + default: us-east1 + ar_repo: + description: "Artifact Registry repository" + required: false + type: string + default: cloud-run-builds + flags: + description: "Extra flags passed directly to gcloud run jobs deploy" + required: false + type: string + default: "" + +jobs: + ci-gate: + runs-on: ubuntu-latest + permissions: + contents: read + checks: read + steps: + - name: Wait for CI to pass + uses: lewagon/wait-on-check-action@9312864dfbc9fd208e9c0417843430751c042800 # v1.7.0 + with: + ref: ${{ github.sha }} + repo-token: ${{ secrets.GITHUB_TOKEN }} + check-regexp: "^(build|lint|typecheck|format-check|test-coverage)$" + wait-interval: 15 + allowed-conclusions: success + + build: + needs: ci-gate + runs-on: ubuntu-latest + environment: ${{ inputs.staging_environment }} + permissions: + contents: read + id-token: write + outputs: + image: ${{ steps.meta.outputs.image }} + version: ${{ steps.meta.outputs.version }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Authenticate to GCP (staging project for AR) + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ vars.WIF_PROVIDER }} + service_account: ${{ vars.WIF_SA }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 + + - name: Authorize Docker to Artifact Registry + run: gcloud auth configure-docker ${{ inputs.region }}-docker.pkg.dev --quiet + + - name: Extract tag version + id: meta + env: + TAG_PREFIX: ${{ inputs.tag_prefix }} + REGION: ${{ inputs.region }} + STAGING_PROJECT: ${{ inputs.staging_project }} + AR_REPO: ${{ inputs.ar_repo }} + IMAGE_NAME: ${{ inputs.image_name }} + run: | + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#"${TAG_PREFIX}"}" + IMAGE="${REGION}-docker.pkg.dev/${STAGING_PROJECT}/${AR_REPO}/${IMAGE_NAME}:${VERSION}" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + env: + DOCKERFILE: ${{ inputs.dockerfile }} + BUILD_CONTEXT: ${{ inputs.build_context }} + run: | + TURBO_VERSION=$(grep '^ turbo:' pnpm-workspace.yaml | awk '{print $2}') + docker build \ + --file "${DOCKERFILE}" \ + --build-arg TURBO_VERSION="${TURBO_VERSION}" \ + --tag "${{ steps.meta.outputs.image }}" \ + "${BUILD_CONTEXT}" + docker push "${{ steps.meta.outputs.image }}" + + deploy-staging: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + environment: + name: ${{ inputs.staging_environment }} + steps: + - name: Authenticate to GCP (staging) + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ vars.WIF_PROVIDER }} + service_account: ${{ vars.WIF_SA }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 + + - name: Deploy Cloud Run Job (staging) + env: + JOB_NAME: ${{ inputs.job_name_staging }} + IMAGE: ${{ needs.build.outputs.image }} + REGION: ${{ inputs.region }} + PROJECT_ID: ${{ inputs.staging_project }} + FLAGS: ${{ inputs.flags }} + run: | + if [[ -n "${FLAGS}" ]]; then + # shellcheck disable=SC2206 + extra_flags=(${FLAGS}) + else + extra_flags=() + fi + + gcloud run jobs deploy "${JOB_NAME}" \ + --image "${IMAGE}" \ + --region "${REGION}" \ + --project "${PROJECT_ID}" \ + "${extra_flags[@]}" + + deploy-prod: + needs: [build, deploy-staging] + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + environment: + name: ${{ inputs.prod_environment }} + steps: + - name: Authenticate to GCP (staging — pull image from staging AR) + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + workload_identity_provider: ${{ vars.WIF_PROVIDER }} + service_account: ${{ vars.WIF_SA }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db # v3.0.1 + + - name: Promote image to production Artifact Registry + env: + REGION: ${{ inputs.region }} + PROD_PROJECT: ${{ inputs.prod_project }} + AR_REPO: ${{ inputs.ar_repo }} + IMAGE_NAME: ${{ inputs.image_name }} + run: | + STAGING_IMAGE="${{ needs.build.outputs.image }}" + PROD_IMAGE="${REGION}-docker.pkg.dev/${PROD_PROJECT}/${AR_REPO}/${IMAGE_NAME}:${STAGING_IMAGE##*:}" + gcloud auth configure-docker ${REGION}-docker.pkg.dev --quiet + docker pull "${STAGING_IMAGE}" + docker tag "${STAGING_IMAGE}" "${PROD_IMAGE}" + docker push "${PROD_IMAGE}" + echo "prod_image=${PROD_IMAGE}" >> "$GITHUB_ENV" + + - name: Deploy Cloud Run Job (prod) + env: + JOB_NAME: ${{ inputs.job_name_prod }} + IMAGE: ${{ env.prod_image }} + REGION: ${{ inputs.region }} + PROJECT_ID: ${{ inputs.prod_project }} + FLAGS: ${{ inputs.flags }} + run: | + if [[ -n "${FLAGS}" ]]; then + # shellcheck disable=SC2206 + extra_flags=(${FLAGS}) + else + extra_flags=() + fi + + gcloud run jobs deploy "${JOB_NAME}" \ + --image "${IMAGE}" \ + --region "${REGION}" \ + --project "${PROJECT_ID}" \ + "${extra_flags[@]}" diff --git a/.github/workflows/_deploy-cloudrun.yml b/.github/workflows/_deploy-cloudrun.yml index ec896037..c3ff8a2b 100644 --- a/.github/workflows/_deploy-cloudrun.yml +++ b/.github/workflows/_deploy-cloudrun.yml @@ -32,6 +32,11 @@ on: description: "Path to the app Dockerfile" required: true type: string + build_context: + description: "Docker build context path" + required: false + type: string + default: "." staging_environment: description: "GitHub Environment name for staging" required: true @@ -42,12 +47,14 @@ on: type: string staging_url: description: "Public staging URL" - required: true + required: false type: string + default: "" prod_url: description: "Public production URL" - required: true + required: false type: string + default: "" region: description: "GCP region" required: false @@ -127,13 +134,14 @@ jobs: - name: Build and push Docker image env: DOCKERFILE: ${{ inputs.dockerfile }} + BUILD_CONTEXT: ${{ inputs.build_context }} run: | TURBO_VERSION=$(grep '^ turbo:' pnpm-workspace.yaml | awk '{print $2}') docker build \ --file "${DOCKERFILE}" \ --build-arg TURBO_VERSION="${TURBO_VERSION}" \ --tag "${{ steps.meta.outputs.image }}" \ - . + "${BUILD_CONTEXT}" docker push "${{ steps.meta.outputs.image }}" # ── Deploy to staging ── diff --git a/.github/workflows/deploy-slackbot.yml b/.github/workflows/deploy-slackbot.yml new file mode 100644 index 00000000..3f555b91 --- /dev/null +++ b/.github/workflows/deploy-slackbot.yml @@ -0,0 +1,50 @@ +name: Deploy Slackbot + +on: + push: + tags: + - "slackbot@*" + +# Prevent concurrent deploys of the same tag. +concurrency: + group: deploy-slackbot-${{ github.ref_name }} + cancel-in-progress: false + +# Reusable workflows can't elevate GITHUB_TOKEN beyond what the caller grants. +# id-token: write -> GCP OIDC (google-github-actions/auth); checks: read -> CI gate. +permissions: + contents: read + id-token: write + checks: read + +jobs: + deploy-main: + uses: ./.github/workflows/_deploy-cloudrun.yml + with: + image_name: f3-slackbot + service_name: f3-nation-slack-bot + staging_project: f3slackbot + prod_project: f3slackbot + tag_prefix: slackbot@ + dockerfile: apps/slackbot/Dockerfile + build_context: apps/slackbot + staging_environment: slackbot-staging + prod_environment: slackbot-production + region: us-central1 + ar_repo: cloud-run-builds + + deploy-scripts: + uses: ./.github/workflows/_deploy-cloudrun-job.yml + with: + image_name: f3-bot-scripts + job_name_staging: f3-bot-scripts-test + job_name_prod: f3-bot-scripts-prod + staging_project: f3slackbot + prod_project: f3slackbot + tag_prefix: slackbot@ + dockerfile: apps/slackbot/scripts/Dockerfile + build_context: apps/slackbot + staging_environment: slackbot-staging + prod_environment: slackbot-production + region: us-central1 + ar_repo: cloud-run-builds diff --git a/apps/slackbot/README.md b/apps/slackbot/README.md index 46803437..cb43ae24 100644 --- a/apps/slackbot/README.md +++ b/apps/slackbot/README.md @@ -79,3 +79,37 @@ pnpm lint - `utilities/slack/orm.py`: legacy custom Slack UI helper layer Data access currently uses SQLAlchemy/f3-data-models (`packages/db-python`) while API migration work continues. + +## Deployment + +Slackbot deploys via tag-based GitHub Actions, similar to the other monorepo apps. + +### Release trigger + +- Tag format: `slackbot@MAJOR.MINOR.PATCH` (example: `slackbot@1.14.0`) +- Trigger workflow: `.github/workflows/deploy-slackbot.yml` + +### What deploys + +One tag deploys two runtimes: + +1. Main app as Cloud Run service +2. Scripts workload as Cloud Run Job (for scheduler-driven runs) + +### Environments and runtime targets + +- GitHub environments: `slackbot-staging`, `slackbot-production` +- GCP project: `f3slackbot` +- Region: `us-central1` +- Main service (both stages via reusable workflow): `f3-nation-slack-bot` +- Scripts job (staging): `f3-bot-scripts-test` +- Scripts job (prod): `f3-bot-scripts-prod` + +### Flow + +1. Push `slackbot@X.Y.Z` tag +2. Workflow waits for CI checks on that commit (`build`, `lint`, `typecheck`, `format-check`, `test-coverage`) +3. Builds and pushes staging images +4. Deploys staging main service and staging scripts job +5. Waits for production environment approval +6. Promotes images and deploys production service and production job diff --git a/apps/slackbot/scripts/cloudbuild.yaml b/apps/slackbot/scripts/cloudbuild.yaml index f9aa80d0..0b6bad96 100644 --- a/apps/slackbot/scripts/cloudbuild.yaml +++ b/apps/slackbot/scripts/cloudbuild.yaml @@ -1,3 +1,6 @@ +# Legacy Cloud Build config retained temporarily during GitHub Actions deploy cutover. +# Primary deployment path is now tag-based workflows in .github/workflows/deploy-slackbot.yml. + steps: - name: gcr.io/cloud-builders/gcloud id: prepare-context diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 822d804c..bed34942 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -20,7 +20,7 @@ feature branch → PR → dev (e.g. me@1.2.0, admin@1.1.0) ↓ deploy-me.yml / deploy-admin.yml - / deploy-auth.yml fires on that tag + / deploy-auth.yml / deploy-slackbot.yml fires on that tag ↓ app is deployed to staging ↓ @@ -72,11 +72,12 @@ On merge of the Release Please PR, the workflow runs again and this time creates The tag triggers the corresponding deploy workflow: -| Tag pattern | Workflow | Environments | -| ----------- | ------------------------------------ | -------------------------------------- | -| `me@*` | `.github/workflows/deploy-me.yml` | `me-staging` → `me.f3nation.com` | -| `admin@*` | `.github/workflows/deploy-admin.yml` | `admin-staging` → `admin.f3nation.com` | -| `auth@*` | `.github/workflows/deploy-auth.yml` | `auth-staging` → `auth.f3nation.com` | +| Tag pattern | Workflow | Environments | +| ------------ | --------------------------------------- | ------------------------------------------ | +| `me@*` | `.github/workflows/deploy-me.yml` | `me-staging` → `me.f3nation.com` | +| `admin@*` | `.github/workflows/deploy-admin.yml` | `admin-staging` → `admin.f3nation.com` | +| `auth@*` | `.github/workflows/deploy-auth.yml` | `auth-staging` → `auth.f3nation.com` | +| `slackbot@*` | `.github/workflows/deploy-slackbot.yml` | `slackbot-staging` → `slackbot-production` | Each deploy workflow: @@ -85,6 +86,11 @@ Each deploy workflow: 3. Deploys to the staging Cloud Run service 4. Promotes the image and deploys to production +For `slackbot@*`, the workflow runs two deploy tracks from one tag: + +1. Main Slackbot Cloud Run service deploy (single reusable `service_name` input across stages) +2. Scripts Cloud Run Job deploy (`f3-bot-scripts-test` staging, `f3-bot-scripts-prod` prod) + --- ## Changelog Format From e98b4577d1c09626f8fc7fe25397b5ba67e025eb Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Sat, 13 Jun 2026 06:15:35 -0500 Subject: [PATCH 05/16] refactor(slackbot): transitioning to GCP deployment conventions --- .github/workflows/_deploy-cloudrun-job.yml | 12 ++++-------- .github/workflows/deploy-slackbot.yml | 15 +++++++-------- apps/slackbot/README.md | 7 +++---- apps/slackbot/scripts/README.md | 6 +++++- docs/GCP_APP_SETUP.md | 22 ++++++++++++++++++++++ docs/RELEASE_PROCESS.md | 4 ++-- 6 files changed, 43 insertions(+), 23 deletions(-) diff --git a/.github/workflows/_deploy-cloudrun-job.yml b/.github/workflows/_deploy-cloudrun-job.yml index 47df9e8c..ac2ef437 100644 --- a/.github/workflows/_deploy-cloudrun-job.yml +++ b/.github/workflows/_deploy-cloudrun-job.yml @@ -10,12 +10,8 @@ on: description: "Artifact Registry image name (e.g. f3-bot-scripts)" required: true type: string - job_name_staging: - description: "Staging Cloud Run Job name" - required: true - type: string - job_name_prod: - description: "Production Cloud Run Job name" + job_name: + description: "Cloud Run Job name" required: true type: string staging_project: @@ -154,7 +150,7 @@ jobs: - name: Deploy Cloud Run Job (staging) env: - JOB_NAME: ${{ inputs.job_name_staging }} + JOB_NAME: ${{ inputs.job_name }} IMAGE: ${{ needs.build.outputs.image }} REGION: ${{ inputs.region }} PROJECT_ID: ${{ inputs.staging_project }} @@ -208,7 +204,7 @@ jobs: - name: Deploy Cloud Run Job (prod) env: - JOB_NAME: ${{ inputs.job_name_prod }} + JOB_NAME: ${{ inputs.job_name }} IMAGE: ${{ env.prod_image }} REGION: ${{ inputs.region }} PROJECT_ID: ${{ inputs.prod_project }} diff --git a/.github/workflows/deploy-slackbot.yml b/.github/workflows/deploy-slackbot.yml index 3f555b91..ad202ff9 100644 --- a/.github/workflows/deploy-slackbot.yml +++ b/.github/workflows/deploy-slackbot.yml @@ -22,9 +22,9 @@ jobs: uses: ./.github/workflows/_deploy-cloudrun.yml with: image_name: f3-slackbot - service_name: f3-nation-slack-bot - staging_project: f3slackbot - prod_project: f3slackbot + service_name: f3-slackbot + staging_project: f3-slackbot-staging + prod_project: f3-slackbot tag_prefix: slackbot@ dockerfile: apps/slackbot/Dockerfile build_context: apps/slackbot @@ -36,11 +36,10 @@ jobs: deploy-scripts: uses: ./.github/workflows/_deploy-cloudrun-job.yml with: - image_name: f3-bot-scripts - job_name_staging: f3-bot-scripts-test - job_name_prod: f3-bot-scripts-prod - staging_project: f3slackbot - prod_project: f3slackbot + image_name: f3-slackbot-scripts + job_name: f3-slackbot-scripts + staging_project: f3-slackbot-staging + prod_project: f3-slackbot tag_prefix: slackbot@ dockerfile: apps/slackbot/scripts/Dockerfile build_context: apps/slackbot diff --git a/apps/slackbot/README.md b/apps/slackbot/README.md index cb43ae24..6dc4f7c7 100644 --- a/apps/slackbot/README.md +++ b/apps/slackbot/README.md @@ -99,11 +99,10 @@ One tag deploys two runtimes: ### Environments and runtime targets - GitHub environments: `slackbot-staging`, `slackbot-production` -- GCP project: `f3slackbot` +- GCP projects: `f3-slackbot-staging` and `f3-slackbot` - Region: `us-central1` -- Main service (both stages via reusable workflow): `f3-nation-slack-bot` -- Scripts job (staging): `f3-bot-scripts-test` -- Scripts job (prod): `f3-bot-scripts-prod` +- Main service (both stages via reusable workflow): `f3-slackbot` +- Scripts job (both stages via reusable workflow): `f3-slackbot-scripts` ### Flow diff --git a/apps/slackbot/scripts/README.md b/apps/slackbot/scripts/README.md index afdd8d92..ca12bf26 100644 --- a/apps/slackbot/scripts/README.md +++ b/apps/slackbot/scripts/README.md @@ -11,6 +11,8 @@ This directory contains the scripts and automation jobs for the F3 Nation Slack ## How to Build the Scripts Image +Primary deployment now happens through GitHub Actions tag releases in [`.github/workflows/deploy-slackbot.yml`](../../../.github/workflows/deploy-slackbot.yml). The Cloud Build config is retained only as a temporary migration path. + 1. **Navigate to this directory:** ```sh @@ -18,12 +20,13 @@ This directory contains the scripts and automation jobs for the F3 Nation Slack ``` 2. **Build the Docker image:** + ```sh gcloud builds submit --tag us-central1-docker.pkg.dev///: . ``` - Replace ``, ``, ``, and `` with your GCP project, Artifact Registry repo, image name, and tag. - - I used `gcloud builds submit --tag us-central1-docker.pkg.dev/f3slackbot/f3-bot-scripts/f3-bot-scripts:v0.1.0 .` + - With the GitHub Actions flow, the scripts image is published as `us-central1-docker.pkg.dev///f3-slackbot-scripts:`. ## How to Run Locally @@ -32,6 +35,7 @@ This directory contains the scripts and automation jobs for the F3 Nation Slack pip install -r requirements.txt ``` 2. **Run the hourly runner:** + ```sh python -m scripts.hourly_runner ``` diff --git a/docs/GCP_APP_SETUP.md b/docs/GCP_APP_SETUP.md index e8ecce0b..c0c99a4c 100644 --- a/docs/GCP_APP_SETUP.md +++ b/docs/GCP_APP_SETUP.md @@ -276,3 +276,25 @@ In the Firebase Console for each project → **App Hosting** → select the back - [ ] Custom domains mapped and DNS updated - [ ] Firebase App Hosting disconnected (if applicable) - [ ] OAuth clients registered with auth provider (if the app uses F3 SSO — see app README) + +--- + +## Slackbot Notes + +Slackbot follows the same setup flow, but it is a Python app and also deploys a separate Cloud Run Job for scripts. + +```bash +APP_NAME="slackbot" +CLOUDRUN_SERVICE="f3-slackbot" +GCP_REGION="us-central1" +GCP_STAGING_PROJECT="f3-slackbot-staging" +GCP_PROD_PROJECT="f3-slackbot" +GH_STAGING_ENV="slackbot-staging" +GH_PROD_ENV="slackbot-production" +``` + +Additional runtime target: + +- Scripts Cloud Run Job: `f3-slackbot-scripts` + +Use the same WIF, environment, secret, and domain-mapping flow above. The main service and scripts job each deploy from the same tag stream via GitHub Actions. diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index bed34942..9c5ee02b 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -88,8 +88,8 @@ Each deploy workflow: For `slackbot@*`, the workflow runs two deploy tracks from one tag: -1. Main Slackbot Cloud Run service deploy (single reusable `service_name` input across stages) -2. Scripts Cloud Run Job deploy (`f3-bot-scripts-test` staging, `f3-bot-scripts-prod` prod) +1. Main Slackbot Cloud Run service deploy (`f3-slackbot` in `f3-slackbot-staging` and `f3-slackbot`) +2. Scripts Cloud Run Job deploy (`f3-slackbot-scripts` in `f3-slackbot-staging` and `f3-slackbot`) --- From 61a8d7e5933ca5e686b2c5d50e0dfa91532db875 Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Sat, 13 Jun 2026 06:19:27 -0500 Subject: [PATCH 06/16] refactor(slackbot): adopting .env.example convention --- apps/slackbot/{.env.local.example => .env.example} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/slackbot/{.env.local.example => .env.example} (100%) diff --git a/apps/slackbot/.env.local.example b/apps/slackbot/.env.example similarity index 100% rename from apps/slackbot/.env.local.example rename to apps/slackbot/.env.example From c7d8e59763aac47ac6a0caa4f5e44abf49a3b73c Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Sun, 14 Jun 2026 06:57:01 -0500 Subject: [PATCH 07/16] refactor(slackbot): cleaning up env var and added script for deploying to GCP --- apps/slackbot/.env.cloud-run.example | 44 ++++ apps/slackbot/.env.example | 16 +- apps/slackbot/features/config.py | 2 +- apps/slackbot/features/db_admin.py | 6 +- apps/slackbot/scripts/cloud-run-env.sh | 265 +++++++++++++++++++++++++ apps/slackbot/utilities/constants.py | 14 +- 6 files changed, 317 insertions(+), 30 deletions(-) create mode 100644 apps/slackbot/.env.cloud-run.example create mode 100644 apps/slackbot/scripts/cloud-run-env.sh diff --git a/apps/slackbot/.env.cloud-run.example b/apps/slackbot/.env.cloud-run.example new file mode 100644 index 00000000..762cf0c7 --- /dev/null +++ b/apps/slackbot/.env.cloud-run.example @@ -0,0 +1,44 @@ +# All variables are plain KEY=VALUE (no "export"). Docker Compose and tools that read .env expect this format. + +# Slack - these come from the Slack App web UI +SLACK_SIGNING_SECRET=your-signing-secret +SLACK_BOT_TOKEN=your-bot-token +SLACK_CLIENT_ID=your-client-id +SLACK_CLIENT_SECRET=your-client-secret +SLACK_SCOPES=app_mentions:read,canvases:read,canvases:write,channels:history,channels:join,channels:manage,channels:read,channels:write.invites,channels:write.topic,chat:write,chat:write.customize,chat:write.public,commands,emoji:read,files:read,files:write,groups:history,groups:read,groups:write,groups:write.invites,groups:write.topic,im:history,im:read,im:write,im:write.topic,incoming-webhook,links.embed:write,links:read,links:write,metadata.message:read,mpim:history,mpim:read,mpim:write,mpim:write.topic,pins:read,pins:write,reactions:read,reactions:write,reminders:read,reminders:write,remote_files:read,remote_files:share,remote_files:write,team:read,usergroups:read,usergroups:write,users.profile:read,users:read,users:read.email,users:write + +# Database (local dev defaults, you are welcome to change these) +DATABASE_HOST=localhost +DATABASE_USER=f3local +DATABASE_PASSWORD=f3local +DATABASE_SCHEMA=f3nation + +# "secret menu" admin password, use after /f3-nation-settings command +SECRET_ADMIN_PASSWORD=set-yourself + +# Used for encrypting email password +PASSWORD_ENCRYPT_KEY=set-yourself + +# F3 Nation Resources +F3_API_KEY=your-api-key +F3_API_BASE_URL=https://api.f3nation.com +STATS_URL=https://pax-vault.f3nation.com +MAP_REVALIDATION_URL=https://staging.api.f3nation.com/v1/map/revalidate +FILE_BUCKET_PREFIX=f3-public-images-staging + +# Misc development settings +SQL_ECHO=False +LOG_LEVEL=INFO +ALL_USERS_ARE_ADMINS=False + +# Cloud Run / non-secret env vars +APP_URL=https://f3-nation-slack-bot-419868269651.us-central1.run.app +ACHIEVEMENTS_ALPHA_TESTING_ORG_IDS=1,49629 +ADMIN_CHANNEL_ID=your-admin-channel-id +STRAVA_CLIENT_ID=your-strava-client-id + +# Cloud Run / secret env vars +ADMIN_BOT_TOKEN=your-admin-bot-token +MAP_REVALIDATION_KEY=your-map-revalidation-key +STRAVA_CLIENT_SECRET=your-strava-client-secret +SENDGRID_API_KEY=your-sendgrid-api-key \ No newline at end of file diff --git a/apps/slackbot/.env.example b/apps/slackbot/.env.example index 3c1bfdd6..d1337485 100644 --- a/apps/slackbot/.env.example +++ b/apps/slackbot/.env.example @@ -14,25 +14,11 @@ DATABASE_SCHEMA=f3nation SQL_ECHO=False # "secret menu" admin password, use after /f3-nation-settings command -DB_ADMIN_PASSWORD=set-yourself +SECRET_ADMIN_PASSWORD=set-yourself # Used for encrypting email password PASSWORD_ENCRYPT_KEY=set-yourself -# Only needed if testing Strava -STRAVA_CLIENT_ID= -STRAVA_CLIENT_SECRET= - -# Only needed if testing AWS S3 image uploads -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= - -# Only needed if testing "legacy" mode or paxminer migrations -PAXMINER_DATABASE_HOST= -PAXMINER_DATABASE_USER= -PAXMINER_DATABASE_PASSWORD= -PAXMINER_DATABASE_SCHEMA= - # F3 Nation API (required for API-backed features like event tags) F3_API_KEY=your-api-key F3_API_BASE_URL=https://api.f3nation.com diff --git a/apps/slackbot/features/config.py b/apps/slackbot/features/config.py index a5d537bb..36f3a3ca 100644 --- a/apps/slackbot/features/config.py +++ b/apps/slackbot/features/config.py @@ -24,7 +24,7 @@ def build_config_form(body: dict, client: WebClient, logger: Logger, context: dict, region_record: SlackSettings): user_id = safe_get(body, "user_id") or safe_get(body, "user", "id") update_view_id = safe_get(body, actions.LOADING_ID) - if body.get("text") == os.environ.get("DB_ADMIN_PASSWORD"): + if body.get("text") == os.environ.get("SECRET_ADMIN_PASSWORD"): db_admin.build_db_admin_form(body, client, logger, context, region_record, update_view_id) else: if ALL_USERS_ARE_ADMINS: diff --git a/apps/slackbot/features/db_admin.py b/apps/slackbot/features/db_admin.py index d380c3ad..496c2099 100644 --- a/apps/slackbot/features/db_admin.py +++ b/apps/slackbot/features/db_admin.py @@ -46,7 +46,7 @@ def build_db_admin_form( message: str = None, ): update_view_id = update_view_id or safe_get(body, actions.LOADING_ID) - if body.get("text") == os.environ.get("DB_ADMIN_PASSWORD") or message: + if body.get("text") == os.environ.get("SECRET_ADMIN_PASSWORD") or message: form = copy.deepcopy(DB_ADMIN_FORM) # form.blocks[-1].label = message or " " else: @@ -71,7 +71,7 @@ def handle_db_admin_upgrade( alembic_cfg = config.Config("alembic.ini") command.upgrade(alembic_cfg, "head") view_id = safe_get(body, "view", "id") - body["text"] = os.environ.get("DB_ADMIN_PASSWORD") + body["text"] = os.environ.get("SECRET_ADMIN_PASSWORD") build_db_admin_form( body, client, logger, context, region_record, update_view_id=view_id, message="Database upgraded!" ) @@ -88,7 +88,7 @@ def handle_db_admin_reset( command.downgrade(alembic_cfg, "base") command.upgrade(alembic_cfg, "head") view_id = safe_get(body, "view", "id") - body["text"] = os.environ.get("DB_ADMIN_PASSWORD") + body["text"] = os.environ.get("SECRET_ADMIN_PASSWORD") build_db_admin_form(body, client, logger, context, region_record, update_view_id=view_id, message="Database reset!") diff --git a/apps/slackbot/scripts/cloud-run-env.sh b/apps/slackbot/scripts/cloud-run-env.sh new file mode 100644 index 00000000..2498856d --- /dev/null +++ b/apps/slackbot/scripts/cloud-run-env.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Push secrets and env vars to GCP Cloud Run for the f3-slackbot service. +# +# Usage: +# bash scripts/cloud-run-env.sh --env staging # reads .env.cloud-run.staging → project f3-slackbot-staging +# bash scripts/cloud-run-env.sh --env prod # reads .env.cloud-run.prod → project f3-slackbot +# +# Each environment is a separate GCP project. Secret names are identical in both +# projects — isolation comes from the project boundary. +# +# This script: +# 1. Creates/updates secrets in GCP Secret Manager +# 2. Updates the Cloud Run service to reference those secrets as env vars +# +# Requires: +# - gcloud CLI authenticated (`gcloud auth login`) +# - .env.cloud-run.prod / .env.cloud-run.staging populated + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Environment → GCP project mapping +declare -A PROJECT_MAP=( + [prod]="f3-slackbot" + [staging]="f3-slackbot-staging" +) + +SERVICE_NAME="f3-slackbot" +REGION="us-central1" + +# Env vars that map to GCP secrets (var name → secret ID) +# Only genuinely sensitive values go here. +declare -A SECRET_MAP=( + [SLACK_SIGNING_SECRET]="slack-signing-secret" + [SLACK_BOT_TOKEN]="slack-bot-token" + [SLACK_CLIENT_SECRET]="slack-client-secret" + [DATABASE_PASSWORD]="database-password" + [SECRET_ADMIN_PASSWORD]="secret-admin-password" + [PASSWORD_ENCRYPT_KEY]="password-encrypt-key" + [F3_API_KEY]="f3-api-key" + [MAP_REVALIDATION_KEY]="map-revalidation-key" + [STRAVA_CLIENT_SECRET]="strava-client-secret" + [ADMIN_BOT_TOKEN]="admin-bot-token" + [SENDGRID_API_KEY]="sendgrid-api-key" +) + +# Per-environment env vars read from the env file (not sensitive, set as plain Cloud Run env vars) +ENV_FILE_VARS=( + SLACK_CLIENT_ID + F3_BASE_URL + STATS_URL + MAP_REVALIDATION_URL + FILE_BUCKET_PREFIX + APP_URL + ALL_USERS_ARE_ADMINS + LOG_LEVEL + SQL_ECHO + DATABASE_HOST + DATABASE_USER + DATABASE_SCHEMA + ACHIEVMENTS_ALPHA_TESTING_ORG_IDS + ADMIN_CHANNEL_ID + STRAVA_CLIENT_ID +) + +# Plain env vars (hardcoded, same across environments) +declare -A PLAIN_VARS=( + [SLACK_SCOPES]="app_mentions:read,canvases:read,canvases:write,channels:history,channels:join,channels:manage,channels:read,channels:write.invites,channels:write.topic,chat:write,chat:write.customize,chat:write.public,commands,emoji:read,files:read,files:write,groups:history,groups:read,groups:write,groups:write.invites,groups:write.topic,im:history,im:read,im:write,im:write.topic,incoming-webhook,links.embed:write,links:read,links:write,metadata.message:read,mpim:history,mpim:read,mpim:write,mpim:write.topic,pins:read,pins:write,reactions:read,reactions:write,reminders:read,reminders:write,remote_files:read,remote_files:share,remote_files:write,team:read,usergroups:read,usergroups:write,users.profile:read,users:read,users:read.email,users:write" + [LOCAL_DEVELOPMENT]=false + [ENABLE_DEBUGGING]=false + [SOCKET_MODE]=false + [LOCAL_HTTP_PORT]=3006 + [USE_GCP_AUTH_PROXY]=true + [PYTHONUNBUFFERED]=1 +) + +# Parse flags +ENV_NAME="" +while [[ $# -gt 0 ]]; do + case "$1" in + --env) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Error: --env requires an argument." + echo "Usage: $0 --env " + exit 1 + fi + ENV_NAME="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" + echo "Usage: $0 --env " + exit 1 + ;; + esac +done + +if [[ -z "$ENV_NAME" ]]; then + echo "Usage: $0 --env " + exit 1 +fi + +if [[ ! "${PROJECT_MAP[$ENV_NAME]+_}" ]]; then + echo "Error: Unknown environment '$ENV_NAME'. Must be 'prod' or 'staging'." + exit 1 +fi + +PROJECT="${PROJECT_MAP[$ENV_NAME]}" +ENV_FILE="$SCRIPT_DIR/../.env.cloud-run.$ENV_NAME" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Error: $ENV_FILE not found." + echo "Copy .env.cloud-run.example and populate with $ENV_NAME values." + exit 1 +fi + +# Safely parse the env file without sourcing to prevent execution of arbitrary shell code +while IFS= read -r line || [[ -n "$line" ]]; do + # Skip blank lines and comments + [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue + # Match only KEY=VALUE pairs (no command substitution or expansion) + if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then + _env_key="${BASH_REMATCH[1]}" + _env_val="${BASH_REMATCH[2]}" + # Strip surrounding single or double quotes + if [[ "$_env_val" =~ ^\"(.*)\"$ ]] || [[ "$_env_val" =~ ^\'(.*)\'$ ]]; then + _env_val="${BASH_REMATCH[1]}" + fi + export "${_env_key}=${_env_val}" + fi +done < "$ENV_FILE" +unset _env_key _env_val + +echo "Environment: $ENV_NAME" +echo "GCP Project: $PROJECT" +echo "Service: $SERVICE_NAME" +echo "Region: $REGION" +echo "Env file: $ENV_FILE" +echo "" + +# ── Push secrets to Secret Manager ── +echo "Pushing secrets to GCP Secret Manager..." + +push_secret() { + local var="$1" secret_id="$2" value="$3" project="$4" + + if [[ -z "$value" ]]; then + echo " SKIP: $var (empty)" + return 0 + fi + + # Create secret if it doesn't exist + if ! gcloud secrets describe "$secret_id" --project "$project" &>/dev/null; then + echo " CREATE: $secret_id" + create_output="" + if ! create_output="$(gcloud secrets create "$secret_id" --project "$project" --replication-policy="automatic" 2>&1)"; then + if grep -qi "already exists" <<<"$create_output"; then + echo " EXISTS: $secret_id" + else + echo " ERROR: failed to create secret $secret_id" + echo "$create_output" + return 1 + fi + fi + + existing="$(gcloud secrets versions access latest --secret="$secret_id" --project "$project" 2>/dev/null)" || existing="" + else + existing="$(gcloud secrets versions access latest --secret="$secret_id" --project "$project" 2>/dev/null)" || existing="" + fi + + if [[ "$existing" == "$value" ]]; then + echo " UNCHANGED: $secret_id" + return 0 + fi + + echo " UPDATE: $secret_id" + printf '%s' "$value" | gcloud secrets versions add "$secret_id" --project "$project" --data-file=- + + # Delete all previous versions (keep only the one we just created) + latest="$(gcloud secrets versions list "$secret_id" --project "$project" \ + --filter="state=ENABLED" --sort-by="~createTime" --limit=1 --format='value(name)' 2>/dev/null)" + while IFS= read -r ver; do + [[ -z "$ver" || "$ver" == "$latest" ]] && continue + echo " DESTROY old version: $ver" + gcloud secrets versions destroy "$ver" --secret="$secret_id" --project "$project" --quiet 2>/dev/null || true + done < <(gcloud secrets versions list "$secret_id" --project "$project" \ + --filter="state!=DESTROYED" --format='value(name)' 2>/dev/null) +} + +# Run secret pushes in parallel +PIDS=() +for var in "${!SECRET_MAP[@]}"; do + push_secret "$var" "${SECRET_MAP[$var]}" "${!var:-}" "$PROJECT" & + PIDS+=($!) +done +FAILED=0 +for pid in "${PIDS[@]}"; do + wait "$pid" || FAILED=1 +done +if [[ "$FAILED" -ne 0 ]]; then + echo "Error: One or more secret pushes failed." + exit 1 +fi + +# ── Grant Cloud Run service account access to secrets ── +echo "" +echo "Granting secret access to Cloud Run service account..." + +SA_EMAIL="$(gcloud run services describe "$SERVICE_NAME" \ + --project "$PROJECT" \ + --region "$REGION" \ + --format='value(spec.template.spec.serviceAccountName)' 2>/dev/null)" || SA_EMAIL="" + +if [[ -z "$SA_EMAIL" ]]; then + PROJECT_NUMBER="$(gcloud projects describe "$PROJECT" --format='value(projectNumber)')" + SA_EMAIL="${PROJECT_NUMBER}-compute@developer.gserviceaccount.com" +fi + +for var in "${!SECRET_MAP[@]}"; do + secret_id="${SECRET_MAP[$var]}" + echo " Granting access to $secret_id..." + if ! gcloud secrets add-iam-policy-binding "$secret_id" \ + --project "$PROJECT" \ + --member "serviceAccount:${SA_EMAIL}" \ + --role "roles/secretmanager.secretAccessor" \ + --quiet > /dev/null; then + echo " ERROR: Failed to grant IAM access for secret $secret_id — aborting." + exit 1 + fi +done + +# ── Build the Cloud Run update command ── +echo "" +echo "Updating Cloud Run service env vars and secret references..." + +UPDATE_ARGS=() + +# Plain env vars (hardcoded) +for var in "${!PLAIN_VARS[@]}"; do + UPDATE_ARGS+=("${var}=${PLAIN_VARS[$var]}") +done + +# Per-environment env vars (from env file, not secrets) +for var in "${ENV_FILE_VARS[@]}"; do + value="${!var:-}" + [[ -n "$value" ]] && UPDATE_ARGS+=("${var}=${value}") +done + +# Secret-backed env vars +SECRET_ARGS=() +for var in "${!SECRET_MAP[@]}"; do + secret_id="${SECRET_MAP[$var]}" + SECRET_ARGS+=("${var}=${secret_id}:latest") +done + +gcloud run services update "$SERVICE_NAME" \ + --project "$PROJECT" \ + --region "$REGION" \ + --update-env-vars "$(IFS=,; echo "${UPDATE_ARGS[*]}")" \ + --update-secrets "$(IFS=,; echo "${SECRET_ARGS[*]}")" \ + --quiet + +echo "" +echo "Done! Service $SERVICE_NAME in $PROJECT updated." diff --git a/apps/slackbot/utilities/constants.py b/apps/slackbot/utilities/constants.py index 3b00547c..66f60a0d 100644 --- a/apps/slackbot/utilities/constants.py +++ b/apps/slackbot/utilities/constants.py @@ -5,11 +5,9 @@ dotenv.load_dotenv() SLACK_BOT_TOKEN = "SLACK_BOT_TOKEN" -SLACK_STATE_S3_BUCKET_NAME = "ENV_SLACK_STATE_S3_BUCKET_NAME" -SLACK_INSTALLATION_S3_BUCKET_NAME = "ENV_SLACK_INSTALLATION_S3_BUCKET_NAME" -SLACK_CLIENT_ID = "ENV_SLACK_CLIENT_ID" -SLACK_CLIENT_SECRET = "ENV_SLACK_CLIENT_SECRET" -SLACK_SCOPES = "ENV_SLACK_SCOPES" +SLACK_CLIENT_ID = "SLACK_CLIENT_ID" +SLACK_CLIENT_SECRET = "SLACK_CLIENT_SECRET" +SLACK_SCOPES = "SLACK_SCOPES" PASSWORD_ENCRYPT_KEY = "PASSWORD_ENCRYPT_KEY" APP_URL = "APP_URL" @@ -31,12 +29,6 @@ ALL_USERS_ARE_ADMINS = os.environ.get("ALL_USERS_ARE_ADMINS", "false").lower() == "true" FILE_BUCKET_PREFIX = os.environ.get("FILE_BUCKET_PREFIX", "f3nation") -SLACK_STATE_S3_BUCKET_NAME = "ENV_SLACK_STATE_S3_BUCKET_NAME" -SLACK_INSTALLATION_S3_BUCKET_NAME = "ENV_SLACK_INSTALLATION_S3_BUCKET_NAME" -SLACK_CLIENT_ID = "ENV_SLACK_CLIENT_ID" -SLACK_CLIENT_SECRET = "ENV_SLACK_CLIENT_SECRET" -SLACK_SCOPES = "ENV_SLACK_SCOPES" - CONFIG_DESTINATION_AO = {"name": "The AO Channel", "value": "ao_channel"} CONFIG_DESTINATION_CURRENT = {"name": "Current Channel", "value": "current_channel"} CONFIG_DESTINATION_SPECIFIED = {"name": "Specified Channel:", "value": "specified_channel"} From 6d095c2804c7bbe26192806c2f0874e0f2b476dc Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Sun, 14 Jun 2026 07:24:24 -0500 Subject: [PATCH 08/16] refactor(slackbot): simplifying startup process --- apps/slackbot/.env.example | 17 ++++++++++------- ...nifest.template.json => app_manifest.json} | 14 ++++---------- apps/slackbot/app_startup.sh | 19 ------------------- scripts/local-setup.sh | 6 +++++- 4 files changed, 19 insertions(+), 37 deletions(-) rename apps/slackbot/{app_manifest.template.json => app_manifest.json} (86%) diff --git a/apps/slackbot/.env.example b/apps/slackbot/.env.example index d1337485..1f954b87 100644 --- a/apps/slackbot/.env.example +++ b/apps/slackbot/.env.example @@ -14,18 +14,21 @@ DATABASE_SCHEMA=f3nation SQL_ECHO=False # "secret menu" admin password, use after /f3-nation-settings command -SECRET_ADMIN_PASSWORD=set-yourself +SECRET_ADMIN_PASSWORD=secret-admin-password # Used for encrypting email password -PASSWORD_ENCRYPT_KEY=set-yourself +PASSWORD_ENCRYPT_KEY=password-encrypt-key -# F3 Nation API (required for API-backed features like event tags) -F3_API_KEY=your-api-key -F3_API_BASE_URL=https://api.f3nation.com +# F3 Nation API +F3_API_KEY=local-slackbot-key +F3_API_BASE_URL=http://localhost:3001 # Misc -STATS_URL=https://pax-vault.vercel.app +STATS_URL=https://pax-vault.f3nation.com LOCAL_DEVELOPMENT=true ENABLE_DEBUGGING=false SOCKET_MODE=true -LOCAL_HTTP_PORT=3006 \ No newline at end of file +LOCAL_HTTP_PORT=3006 + +GCS_EMULATOR_HOST=localhost:9023 +FILE_BUCKET_PREFIX=f3-public-images \ No newline at end of file diff --git a/apps/slackbot/app_manifest.template.json b/apps/slackbot/app_manifest.json similarity index 86% rename from apps/slackbot/app_manifest.template.json rename to apps/slackbot/app_manifest.json index 406cd92e..65015662 100644 --- a/apps/slackbot/app_manifest.template.json +++ b/apps/slackbot/app_manifest.json @@ -44,44 +44,38 @@ "slash_commands": [ { "command": "/preblast", - "url": "https://HOST-PLACEHOLDER/slack/events", "description": "Launch preblast form", "should_escape": false }, { "command": "/f3-nation-settings", - "url": "https://HOST-PLACEHOLDER/slack/events", "description": "Managers your region's settings for F3 Nation, including your schedule", "should_escape": false }, { "command": "/backblast", - "url": "https://HOST-PLACEHOLDER/slack/events", "description": "Launch backblast form", "should_escape": false }, { "command": "/tag-achievement", - "url": "https://HOST-PLACEHOLDER/slack/events", "description": "Lauches a form for manually tagging achievements", "should_escape": false }, { "command": "/f3-calendar", - "url": "https://HOST-PLACEHOLDER/slack/events", "description": "Opens the event calendar", "should_escape": false }, { "command": "/help", - "url": "https://HOST-PLACEHOLDER/slack/events", "description": "Opens a help menu", "should_escape": false } ] }, "oauth_config": { - "redirect_urls": ["https://HOST-PLACEHOLDER/slack/install"], + "redirect_urls": ["https://localhost:3006/slack/install"], "scopes": { "user": ["files:write"], "bot": [ @@ -140,13 +134,13 @@ }, "settings": { "event_subscriptions": { - "request_url": "https://HOST-PLACEHOLDER/slack/events", + "request_url": "https://localhost:3006/slack/events", "bot_events": ["app_mention", "team_join"] }, "interactivity": { "is_enabled": true, - "request_url": "https://HOST-PLACEHOLDER/slack/events", - "message_menu_options_url": "https://HOST-PLACEHOLDER/slack/events" + "request_url": "https://localhost:3006/slack/events", + "message_menu_options_url": "https://localhost:3006/slack/events" }, "org_deploy_enabled": false, "socket_mode_enabled": false, diff --git a/apps/slackbot/app_startup.sh b/apps/slackbot/app_startup.sh index 81397fed..8f4dd67e 100755 --- a/apps/slackbot/app_startup.sh +++ b/apps/slackbot/app_startup.sh @@ -6,7 +6,6 @@ set -Eeu -o pipefail # Simple supervisor that keeps the local server running in Socket Mode. APP_PID="" STOPPING=false -TEMPLATE_FILE="app_manifest.template.json" ENV_FILE=".env" SOCKET_MODE_VAR="SOCKET_MODE" LOCAL_HTTP_PORT_VAR="LOCAL_HTTP_PORT" @@ -56,23 +55,6 @@ ensure_local_http_port() { fi } -generate_manifest() { - local host="$1" - echo "Generating app_manifest.json for host: ${host}" - if [[ ! -f "${TEMPLATE_FILE}" ]]; then - echo "Template file not found: ${TEMPLATE_FILE}" - exit 1 - fi - # First apply HOST-PLACEHOLDER substitution - sed "s|HOST-PLACEHOLDER|${host}|g" "${TEMPLATE_FILE}" > app_manifest.json - - tmp_file="app_manifest.tmp.json" - sed '/"url": "https:\/\//d' app_manifest.json > "${tmp_file}" - sed 's/"socket_mode_enabled": false/"socket_mode_enabled": true/' "${tmp_file}" > app_manifest.json - rm -f "${tmp_file}" - echo "Generated app_manifest.json for SOCKET_MODE (no slash command URLs, socket_mode_enabled=true)" -} - start_app() { echo "Starting the app with watchfiles..." ( watchfiles --filter python 'dotenv -f .env run -- python main.py' ) & @@ -101,7 +83,6 @@ if [[ "${SOCKET_MODE}" != "true" ]]; then fi echo "SOCKET_MODE=true; localtunnel is disabled." -generate_manifest "localhost:3006" while true; do start_app diff --git a/scripts/local-setup.sh b/scripts/local-setup.sh index daab798b..c32f41ba 100755 --- a/scripts/local-setup.sh +++ b/scripts/local-setup.sh @@ -121,7 +121,11 @@ echo " 1. Set NEXT_PUBLIC_GOOGLE_API_KEY in apps/map/.env, apps/api/.env, and echo " (map tiles won't load without it)" echo " Get one free at: https://console.cloud.google.com/google/maps-apis/" echo "" -echo " 2. Start the app servers:" +echo " 2. (Optional) If you want to run slackbot, you will need to set up a Slack app at https://api.slack.com/apps" +echo " and populate apps/slackbot/.env with the appropriate credentials." +echo " See apps/slackbot/README.md for details." +echo "" +echo " 3. Start the app servers:" echo " pnpm dev" echo "" echo " Daily workflow:" From 6e96cd608dd201dbdaf42b2ed05981fdca063bab Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Sun, 14 Jun 2026 07:58:19 -0500 Subject: [PATCH 09/16] refactor(slackbot): making it easier to connect to locally seeded data --- apps/slackbot/features/connect.py | 34 +++++++++++++-------- apps/slackbot/utilities/helper_functions.py | 12 +------- packages/db/package.json | 1 + pnpm-lock.yaml | 31 +++++++++++-------- 4 files changed, 41 insertions(+), 37 deletions(-) diff --git a/apps/slackbot/features/connect.py b/apps/slackbot/features/connect.py index f2d17ee8..8415717f 100644 --- a/apps/slackbot/features/connect.py +++ b/apps/slackbot/features/connect.py @@ -31,6 +31,7 @@ update_local_region_records, ) from utilities.slack.actions import LOADING_ID +from utilities.constants import LOCAL_DEVELOPMENT CONNECT_EXISTING_REGION = "connect_existing_region" CREATE_NEW_REGION = "create_new_region" @@ -162,10 +163,28 @@ def handle_existing_region_selection( state = ViewState(**safe_get(body, "view", "state")) region_select = state.values.get(SELECT_REGION).get(SELECT_REGION) date_select = state.values.get(MIGRATION_DATE).get(MIGRATION_DATE) - user_info = client.users_info(user=safe_get(body, "user", "id")) - user_name = safe_get(user_info, "user", "profile", "display_name") or safe_get(user_info, "user", "name") + user_id = safe_get(body, "user", "id") + user_info = client.users_info(user=user_id) team_id = safe_get(body, "team", "id") + user_name = safe_get(user_info, "user", "profile", "display_name") or safe_get(user_info, "user", "name") region_record = region_record or get_region_record(team_id, body, context, client, logger) + metadata = { + "event_type": "region_connection_request", + "event_payload": { + "user_id": safe_get(body, "user", "id"), + "requestor_bot_token": region_record.bot_token, + "region_id": region_select.selected_option.get("value"), + "region_name": region_select.selected_option.get("text").get("text"), + "migration_date": date_select.selected_date, + "team_id": team_id, + }, + } + + if LOCAL_DEVELOPMENT: + body["message"] = {"metadata": metadata} + handle_approve_connection(body, client, logger, context, region_record) + return + blocks = [ HeaderBlock(text="Region Connection Request"), RichTextBlock( @@ -235,17 +254,6 @@ def handle_existing_region_selection( ] ), ] - metadata = { - "event_type": "region_connection_request", - "event_payload": { - "user_id": safe_get(body, "user", "id"), - "requestor_bot_token": region_record.bot_token, - "region_id": region_select.selected_option.get("value"), - "region_name": region_select.selected_option.get("text").get("text"), - "migration_date": date_select.selected_date, - "team_id": team_id, - }, - } # noqa ssl_context = ssl.create_default_context() ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE diff --git a/apps/slackbot/utilities/helper_functions.py b/apps/slackbot/utilities/helper_functions.py index f313df94..9c2a25c1 100644 --- a/apps/slackbot/utilities/helper_functions.py +++ b/apps/slackbot/utilities/helper_functions.py @@ -358,17 +358,7 @@ def get_region_record(team_id: str, body, context, client, logger) -> SlackSetti "workspace_name": team_name, } region_record = SlackSettings(**settings_starters) - if not org_record: - if LOCAL_DEVELOPMENT: - org_record = DbManager.create_record( - Org( - name="My Region", - org_type=Org_Type.region, - is_active=True, - ) - ) - region_record.org_id = org_record.id - else: + if org_record: settings_starters.update({"org_id": org_record.id}) region_record = SlackSettings(**settings_starters) diff --git a/packages/db/package.json b/packages/db/package.json index 86bcdd92..3427eb13 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -46,6 +46,7 @@ "drizzle-kit": "catalog:", "eslint": "catalog:", "prettier": "catalog:", + "tsx": "catalog:", "typescript": "catalog:" }, "prettier": "@acme/prettier-config" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 000b89fd..6d48f100 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -522,7 +522,7 @@ importers: version: 7.4.4 geist: specifier: 'catalog:' - version: 1.7.0(next@15.5.18(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 1.7.0(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) jose: specifier: 'catalog:' version: 6.2.2 @@ -652,7 +652,7 @@ importers: version: 0.9.26(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: 'catalog:' - version: 9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.18(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4) + version: 9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4) '@t3-oss/env-nextjs': specifier: 'catalog:' version: 0.9.2(typescript@6.0.3)(zod@3.25.76) @@ -676,7 +676,7 @@ importers: version: 7.4.4 geist: specifier: 'catalog:' - version: 1.7.0(next@15.5.18(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 1.7.0(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) handlebars: specifier: 'catalog:' version: 4.7.9 @@ -694,10 +694,10 @@ importers: version: 15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-auth: specifier: 'catalog:' - version: 5.0.0-beta.30(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 5.0.0-beta.30(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@8.0.10)(react@18.3.1) next-plausible: specifier: 'catalog:' - version: 3.12.5(next@15.5.18(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.12.5(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: 'catalog:' version: 18.3.1 @@ -1015,7 +1015,7 @@ importers: version: 3.1.1(react@18.3.1) '@sentry/nextjs': specifier: 'catalog:' - version: 9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.18(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4) + version: 9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4) '@t3-oss/env-nextjs': specifier: 'catalog:' version: 0.9.2(typescript@6.0.3)(zod@3.25.76) @@ -1039,7 +1039,7 @@ importers: version: 7.4.4 geist: specifier: 'catalog:' - version: 1.7.0(next@15.5.18(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + version: 1.7.0(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) google-auth-library: specifier: 'catalog:' version: 10.6.2 @@ -1063,10 +1063,10 @@ importers: version: 15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-auth: specifier: 'catalog:' - version: 5.0.0-beta.30(next@15.5.18(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@8.0.10)(react@18.3.1) + version: 5.0.0-beta.30(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@8.0.10)(react@18.3.1) next-plausible: specifier: 'catalog:' - version: 3.12.5(next@15.5.18(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.12.5(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1503,6 +1503,9 @@ importers: prettier: specifier: 'catalog:' version: 3.8.3 + tsx: + specifier: 'catalog:' + version: 4.22.3 typescript: specifier: 'catalog:' version: 6.0.3 @@ -12228,7 +12231,7 @@ snapshots: '@sentry/core@9.47.1': {} - '@sentry/nextjs@9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.18(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4)': + '@sentry/nextjs@9.47.1(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1))(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(webpack@5.105.4)': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 @@ -14370,7 +14373,7 @@ snapshots: transitivePeerDependencies: - supports-color - geist@1.7.0(next@15.5.18(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): + geist@1.7.0(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: next: 15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -15335,11 +15338,13 @@ snapshots: netmask@2.1.0: {} - next-auth@5.0.0-beta.30(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + next-auth@5.0.0-beta.30(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@8.0.10)(react@18.3.1): dependencies: '@auth/core': 0.41.0(nodemailer@8.0.10) next: 15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 + optionalDependencies: + nodemailer: 8.0.10 next-auth@5.0.0-beta.30(next@15.5.18(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@8.0.10)(react@18.3.1): dependencies: @@ -15349,7 +15354,7 @@ snapshots: optionalDependencies: nodemailer: 8.0.10 - next-plausible@3.12.5(next@15.5.18(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-plausible@3.12.5(next@15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: next: 15.5.18(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 From a74a32fb1b428b6647c43c6f2401e2caa7d99b4f Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Sun, 14 Jun 2026 08:14:00 -0500 Subject: [PATCH 10/16] refactor(slackbot): adding more graceful handling of slack secrets not set --- apps/slackbot/main.py | 46 ++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/apps/slackbot/main.py b/apps/slackbot/main.py index 7a920338..1af46b39 100644 --- a/apps/slackbot/main.py +++ b/apps/slackbot/main.py @@ -63,10 +63,13 @@ def setup_debugger(): handler = StructuredLogHandler() setup_logging(handler, log_level=logging_level) -app = App( - process_before_response=process_before_response, - oauth_settings=get_oauth_settings(), -) +try: + app = App( + process_before_response=process_before_response, + oauth_settings=get_oauth_settings(), + ) +except Exception as exc: + print(f"Error initializing Slackbot: you may need to set up your .env file with the appropriate Slack credentials. Exception: {exc}") # ---------------------------------------- # Production Mode: Google Cloud Function HTTP Handler @@ -154,18 +157,20 @@ def main_response(body: dict, logger: logging.Logger, client: WebClient, ack: Ac f"{safe_get(safe_get(MAIN_MAPPER, request_type), request_id) or request_type + ', ' + request_id}" ) +try: + ARGS = [main_response] + LAZY_KWARGS = {} -ARGS = [main_response] -LAZY_KWARGS = {} - -MATCH_ALL_PATTERN = re.compile(".*") -app.action(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) -app.view(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) -app.command(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) -app.view_closed(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) -app.event(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) -app.options(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) -app.shortcut(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) + MATCH_ALL_PATTERN = re.compile(".*") + app.action(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) + app.view(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) + app.command(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) + app.view_closed(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) + app.event(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) + app.options(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) + app.shortcut(MATCH_ALL_PATTERN)(*ARGS, **LAZY_KWARGS) +except Exception as exc: + pass def start_local_health_server(port: int): @@ -191,6 +196,12 @@ def log_message(self, fmt, *args): if LOCAL_DEVELOPMENT and not SOCKET_MODE: raise RuntimeError("Local development requires SOCKET_MODE=true.") + + # Ensure SLACK_APP_TOKEN is present + app_token = os.environ.get("SLACK_APP_TOKEN") + if not app_token: + print("Error: SLACK_APP_TOKEN is required to run the Slackbot. Please set it in your .env file.") + exit(1) if not SOCKET_MODE: try: @@ -206,10 +217,5 @@ def log_message(self, fmt, *args): print("Running in local Socket Mode.", flush=True) - # Ensure SLACK_APP_TOKEN is present - app_token = os.environ.get("SLACK_APP_TOKEN") - if not app_token: - raise RuntimeError("SLACK_APP_TOKEN missing. Check your .env file.") - handler = SocketModeHandler(app, app_token) handler.start() From 411408538696ffa90de74b7db16e8061b76dc8e7 Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Mon, 15 Jun 2026 07:21:54 -0500 Subject: [PATCH 11/16] refactor(slackbot): misc deployment and docs updates --- .dockerignore | 2 + .github/workflows/deploy-slackbot.yml | 4 +- .vscode/settings.json | 4 +- apps/slackbot/.vscode/launch.json | 15 --- apps/slackbot/Dockerfile | 27 ++++- apps/slackbot/README.md | 28 +---- apps/slackbot/features/db_admin.py | 41 ------- apps/slackbot/pyproject.toml | 1 - apps/slackbot/scripts/Dockerfile | 28 +++-- apps/slackbot/scripts/README.md | 18 +-- uv.lock | 159 -------------------------- 11 files changed, 63 insertions(+), 264 deletions(-) delete mode 100644 apps/slackbot/.vscode/launch.json diff --git a/.dockerignore b/.dockerignore index d822131b..c7dc75e9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,6 +13,8 @@ npm-debug.log *Dockerfile* docker-compose* README.md +!apps/slackbot/README.md +!packages/db-python/README.md LICENSE *.swp diff --git a/.github/workflows/deploy-slackbot.yml b/.github/workflows/deploy-slackbot.yml index ad202ff9..be68d907 100644 --- a/.github/workflows/deploy-slackbot.yml +++ b/.github/workflows/deploy-slackbot.yml @@ -27,7 +27,7 @@ jobs: prod_project: f3-slackbot tag_prefix: slackbot@ dockerfile: apps/slackbot/Dockerfile - build_context: apps/slackbot + build_context: . staging_environment: slackbot-staging prod_environment: slackbot-production region: us-central1 @@ -42,7 +42,7 @@ jobs: prod_project: f3-slackbot tag_prefix: slackbot@ dockerfile: apps/slackbot/scripts/Dockerfile - build_context: apps/slackbot + build_context: . staging_environment: slackbot-staging prod_environment: slackbot-production region: us-central1 diff --git a/.vscode/settings.json b/.vscode/settings.json index e9bb9e4d..27e1b878 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,5 +23,7 @@ "typescript.tsdk": "node_modules/typescript/lib", "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml" - } + }, + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.analysis.extraPaths": ["${workspaceFolder}/packages/db-python"] } diff --git a/apps/slackbot/.vscode/launch.json b/apps/slackbot/.vscode/launch.json deleted file mode 100644 index 6e37c6ef..00000000 --- a/apps/slackbot/.vscode/launch.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Attach to debugpy (F3 bot)", - "type": "debugpy", - "request": "attach", - "connect": { - "host": "localhost", - "port": 5678 - }, - "justMyCode": false - } - ] -} diff --git a/apps/slackbot/Dockerfile b/apps/slackbot/Dockerfile index ba8a4a49..c40ac66d 100644 --- a/apps/slackbot/Dockerfile +++ b/apps/slackbot/Dockerfile @@ -1,9 +1,24 @@ -FROM python:3.12-slim +FROM --platform=linux/amd64 python:3.12-slim -ENV PYTHONUNBUFFERED=1 APP_HOME=/app GOOGLE_FUNCTION_TARGET=handler -WORKDIR $APP_HOME +COPY --from=ghcr.io/astral-sh/uv:0.9.17 /uv /uvx /bin/ + +ENV PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/app/.venv \ + PATH="/app/.venv/bin:$PATH" \ + APP_HOME=/app/apps/slackbot \ + GOOGLE_FUNCTION_TARGET=handler + +WORKDIR /app -COPY . ./ -RUN pip install --no-cache-dir . +COPY pyproject.toml uv.lock ./ +COPY packages/db-python ./packages/db-python +COPY apps/slackbot ./apps/slackbot + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --package f3-nation-slack-bot --no-dev --no-editable + +WORKDIR $APP_HOME EXPOSE 8080 -CMD ["functions-framework", "--target", "handler", "--port", "8080"] \ No newline at end of file +CMD ["functions-framework", "--target", "handler", "--port", "8080"] diff --git a/apps/slackbot/README.md b/apps/slackbot/README.md index 6dc4f7c7..fa9975c2 100644 --- a/apps/slackbot/README.md +++ b/apps/slackbot/README.md @@ -6,12 +6,6 @@ F3 Nation Slack Bot runs inside the monorepo and now follows the same local work ## Local development (monorepo) -### Prerequisites - -1. Docker running locally -2. Node and pnpm installed for the monorepo -3. `uv` available for Python dependency/runtime management - ### One-time setup From the repo root: @@ -22,13 +16,15 @@ pnpm local:setup This creates `apps/slackbot/.env` from `apps/slackbot/.env.local.example`, then starts shared Docker services and runs DB migration/seed steps. -### Configure Slack credentials +### Create Slack app and configure Slack credentials -Edit `apps/slackbot/.env` and set at minimum: +1. **Initialize and install your local Slack app**: I recommend you use your own private Slack workspace for this. Open [Slack's app console](https://api.slack.com/apps), click Create New App->from manifest, then paste in the contents from `app_manifest.json`. After you install to your workspace, gather the Signing Secret from the Basic Information tab and the Bot User OAuth Token from the OAuth & Permissions tab. For the app-level token, you will need to generate this from the Basic Information tab. + +2. **Copy to `.env`**: edit `apps/slackbot/.env` and set: - `SLACK_SIGNING_SECRET` - `SLACK_BOT_TOKEN` -- `SLACK_APP_TOKEN` (required for Socket Mode) +- `SLACK_APP_TOKEN` ### Start all local apps @@ -38,21 +34,9 @@ From the repo root: pnpm dev ``` -Slackbot starts automatically with the rest of the workspace apps. +Slackbot starts automatically with the rest of the monorepo apps. - Slackbot local URL: http://localhost:3006 -- Connection mode: Socket Mode only (no localtunnel) - -## Slack app manifest workflow - -At startup, `app_startup.sh` regenerates `app_manifest.json` from `app_manifest.template.json`. - -1. Start dev (`pnpm dev`) so `app_manifest.json` is generated. -2. In Slack app settings, open **App Manifest**. -3. Replace the manifest with `apps/slackbot/app_manifest.json`. -4. Save and reinstall if prompted. - -The generated manifest enables Socket Mode and removes slash command URLs that are only needed for tunnel-based HTTP event delivery. ## Step debugging diff --git a/apps/slackbot/features/db_admin.py b/apps/slackbot/features/db_admin.py index 496c2099..b6b7ce09 100644 --- a/apps/slackbot/features/db_admin.py +++ b/apps/slackbot/features/db_admin.py @@ -3,8 +3,6 @@ import ssl from logging import Logger -from alembic import command, config, script -from alembic.runtime import migration from f3_data_models.models import Org, Org_Type, Org_x_SlackSpace, Role, Role_x_User_x_Org, SlackSpace from f3_data_models.utils import DbManager from slack_sdk.web import WebClient @@ -28,14 +26,6 @@ from utilities.slack import actions, orm -def check_current_head(alembic_cfg: config.Config, connectable: engine.Engine) -> bool: - # type: (config.Config, engine.Engine) -> bool - directory = script.ScriptDirectory.from_config(alembic_cfg) - with connectable.begin() as connection: - context = migration.MigrationContext.configure(connection) - return set(context.get_current_heads()) == set(directory.get_heads()) - - def build_db_admin_form( body: dict, client: WebClient, @@ -61,37 +51,6 @@ def build_db_admin_form( ) -def handle_db_admin_upgrade( - body: dict, - client: WebClient, - logger: Logger, - context: dict, - region_record: SlackSettings, -): - alembic_cfg = config.Config("alembic.ini") - command.upgrade(alembic_cfg, "head") - view_id = safe_get(body, "view", "id") - body["text"] = os.environ.get("SECRET_ADMIN_PASSWORD") - build_db_admin_form( - body, client, logger, context, region_record, update_view_id=view_id, message="Database upgraded!" - ) - - -def handle_db_admin_reset( - body: dict, - client: WebClient, - logger: Logger, - context: dict, - region_record: SlackSettings, -): - alembic_cfg = config.Config("alembic.ini") - command.downgrade(alembic_cfg, "base") - command.upgrade(alembic_cfg, "head") - view_id = safe_get(body, "view", "id") - body["text"] = os.environ.get("SECRET_ADMIN_PASSWORD") - build_db_admin_form(body, client, logger, context, region_record, update_view_id=view_id, message="Database reset!") - - def handle_calendar_image_refresh( body: dict, client: WebClient, diff --git a/apps/slackbot/pyproject.toml b/apps/slackbot/pyproject.toml index 0dae6847..6401b5a9 100644 --- a/apps/slackbot/pyproject.toml +++ b/apps/slackbot/pyproject.toml @@ -67,7 +67,6 @@ dev = [ "playwright>=1.44.0,<2.0.0", "dataframe-image>=0.2.7,<0.3.0", "pandas>=2.2.2,<3.0.0", - "alembic>=1.13.0,<2.0.0", "debugpy>=1.8.17,<2.0.0", "commitizen>=4.13.9,<5.0.0", ] diff --git a/apps/slackbot/scripts/Dockerfile b/apps/slackbot/scripts/Dockerfile index 9703bf4f..1a18a913 100644 --- a/apps/slackbot/scripts/Dockerfile +++ b/apps/slackbot/scripts/Dockerfile @@ -1,7 +1,15 @@ -FROM python:3.12-slim +FROM --platform=linux/amd64 python:3.12-slim -ENV PYTHONUNBUFFERED=1 APP_HOME=/app -WORKDIR $APP_HOME +COPY --from=ghcr.io/astral-sh/uv:0.9.17 /uv /uvx /bin/ + +ENV PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/app/.venv \ + PATH="/app/.venv/bin:$PATH" \ + APP_HOME=/app/apps/slackbot + +WORKDIR /app # Install system dependencies for Playwright/Chromium RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -38,12 +46,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libatspi2.0-0 \ && rm -rf /var/lib/apt/lists/* -COPY scripts/requirements.txt ./requirements.txt -RUN pip install --no-cache-dir -r requirements.txt +COPY pyproject.toml uv.lock ./ +COPY packages/db-python ./packages/db-python +COPY apps/slackbot ./apps/slackbot -RUN pip install --no-cache-dir playwright -RUN python -m playwright install chromium +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --package f3-nation-slack-bot --group dev --no-editable -COPY . ./ +WORKDIR $APP_HOME +RUN python -m playwright install chromium -CMD ["python", "-m", "scripts.hourly_runner"] \ No newline at end of file +CMD ["python", "-m", "scripts.hourly_runner"] diff --git a/apps/slackbot/scripts/README.md b/apps/slackbot/scripts/README.md index ca12bf26..7fb89189 100644 --- a/apps/slackbot/scripts/README.md +++ b/apps/slackbot/scripts/README.md @@ -4,8 +4,7 @@ This directory contains the scripts and automation jobs for the F3 Nation Slack ## Structure -- `Dockerfile` — Dockerfile for building the scripts container image (includes all heavy dependencies) -- `requirements.txt` — Python dependencies for scripts (can include plotting, Playwright, pandas, etc.) +- `Dockerfile` — Dockerfile for building the scripts container image with uv and the Slackbot `dev` dependency group - `hourly_runner.py` — Entrypoint for running all hourly scripts - Other Python scripts for specific automation tasks @@ -13,16 +12,19 @@ This directory contains the scripts and automation jobs for the F3 Nation Slack Primary deployment now happens through GitHub Actions tag releases in [`.github/workflows/deploy-slackbot.yml`](../../../.github/workflows/deploy-slackbot.yml). The Cloud Build config is retained only as a temporary migration path. -1. **Navigate to this directory:** +1. **Navigate to the repository root:** ```sh - cd scripts + cd ../../.. ``` 2. **Build the Docker image:** ```sh - gcloud builds submit --tag us-central1-docker.pkg.dev///: . + docker build \ + --file apps/slackbot/scripts/Dockerfile \ + --tag us-central1-docker.pkg.dev///: \ + . ``` - Replace ``, ``, ``, and `` with your GCP project, Artifact Registry repo, image name, and tag. @@ -32,12 +34,12 @@ Primary deployment now happens through GitHub Actions tag releases in [`.github/ 1. **Install dependencies:** ```sh - pip install -r requirements.txt + uv sync --package f3-nation-slack-bot --group dev ``` 2. **Run the hourly runner:** ```sh - python -m scripts.hourly_runner + uv run --package f3-nation-slack-bot python -m scripts.hourly_runner ``` - You can pass arguments like `--force` or `--skip-reporting` as needed. @@ -51,4 +53,4 @@ Primary deployment now happens through GitHub Actions tag releases in [`.github/ ## Notes - This image is intended for Cloud Run Jobs and includes heavy dependencies not needed by the main app. -- Keep the main app's Dockerfile and requirements.txt in the project root for a slim deployment. +- The main app image uses the default dependency set only; this scripts image adds the `dev` group for Playwright, pandas, and reporting tools. diff --git a/uv.lock b/uv.lock index 23564d67..22cdd9e9 100644 --- a/uv.lock +++ b/uv.lock @@ -145,20 +145,6 @@ 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 = "alembic" -version = "1.18.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mako" }, - { name = "sqlalchemy" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, -] - [[package]] name = "annotated-types" version = "0.7.0" @@ -470,18 +456,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, ] -[[package]] -name = "click-option-group" -version = "0.5.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/ff/d291d66595b30b83d1cb9e314b2c9be7cfc7327d4a0d40a15da2416ea97b/click_option_group-0.5.9.tar.gz", hash = "sha256:f94ed2bc4cf69052e0f29592bd1e771a1789bd7bfc482dd0bc482134aff95823", size = 22222, upload-time = "2025-10-09T09:38:01.474Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/75/45/54bb2d8d4138964a94bef6e9afe48b0be4705ba66ac442ae7d8a8dc4ffef/click_option_group-0.5.9-py3-none-any.whl", hash = "sha256:ad2599248bd373e2e19bec5407967c3eec1d0d4fc4a5e77b08a0481e75991080", size = 11553, upload-time = "2025-10-09T09:38:00.066Z" }, -] - [[package]] name = "cloud-sql-python-connector" version = "1.20.3" @@ -827,15 +801,6 @@ 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 = "dotty-dict" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/ab/88d67f02024700b48cd8232579ad1316aa9df2272c63049c27cc094229d6/dotty_dict-1.3.1.tar.gz", hash = "sha256:4b016e03b8ae265539757a53eba24b9bfda506fb94fbce0bee843c6f05541a15", size = 7699, upload-time = "2022-07-09T18:50:57.727Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014, upload-time = "2022-07-09T18:50:55.058Z" }, -] - [[package]] name = "encutils" version = "1.0.0" @@ -923,7 +888,6 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "alembic" }, { name = "commitizen" }, { name = "dataframe-image" }, { name = "debugpy" }, @@ -963,7 +927,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "alembic", specifier = ">=1.13.0,<2.0.0" }, { name = "commitizen", specifier = ">=4.13.9,<5.0.0" }, { name = "dataframe-image", specifier = ">=0.2.7,<0.3.0" }, { name = "debugpy", specifier = ">=1.8.17,<2.0.0" }, @@ -1163,30 +1126,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/0e/f2cbbd4eb81646b3e09093b4df903c4df60c4791f2a4c6d41e5f3a56b491/functions_framework-3.10.1-py3-none-any.whl", hash = "sha256:48e7fd752d32dfeb528d1c9bf5d95960b6f0bb392f2a4da689f4d3c7a82c1230", size = 41406, upload-time = "2026-02-17T20:39:41.455Z" }, ] -[[package]] -name = "gitdb" -version = "4.0.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "smmap" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, -] - -[[package]] -name = "gitpython" -version = "3.1.50" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gitdb" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" }, -] - [[package]] name = "google-api-core" version = "2.31.0" @@ -1539,15 +1478,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] -[[package]] -name = "importlib-resources" -version = "6.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -1904,30 +1834,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, ] -[[package]] -name = "mako" -version = "1.3.12" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, -] - [[package]] name = "markupsafe" version = "3.0.3" @@ -2057,15 +1963,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - [[package]] name = "mistune" version = "3.2.1" @@ -3097,19 +2994,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] -[[package]] -name = "python-gitlab" -version = "6.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, - { name = "requests-toolbelt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9a/bd/b30f1d3b303cb5d3c72e2d57a847d699e8573cbdfd67ece5f1795e49da1c/python_gitlab-6.5.0.tar.gz", hash = "sha256:97553652d94b02de343e9ca92782239aa2b5f6594c5482331a9490d9d5e8737d", size = 400591, upload-time = "2025-10-17T21:40:02.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/bd/b0d440685fbcafee462bed793a74aea88541887c4c30556a55ac64914b8d/python_gitlab-6.5.0-py3-none-any.whl", hash = "sha256:494e1e8e5edd15286eaf7c286f3a06652688f1ee20a49e2a0218ddc5cc475e32", size = 144419, upload-time = "2025-10-17T21:40:01.233Z" }, -] - [[package]] name = "python-http-client" version = "3.3.7" @@ -3271,31 +3155,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/bb/5deac77a9af870143c684ab46a7934038a53eb4aa975bc0687ed6ca2c610/requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5", size = 23892, upload-time = "2022-01-29T18:52:22.279Z" }, ] -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, -] - -[[package]] -name = "rich" -version = "14.3.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, -] - [[package]] name = "rpds-py" version = "2026.5.1" @@ -3441,15 +3300,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - [[package]] name = "simplejson" version = "4.1.1" @@ -3533,15 +3383,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/ef/8a1556bd4843443993fc116783790a7cc553601a37f7d965ec26eef95e76/slack_sdk-3.42.0-py2.py3-none-any.whl", hash = "sha256:eb39aff97e476e10cc5a8ac29bd2e79a9959e880d9fe0c03b4e8f05b2ac996ff", size = 315469, upload-time = "2026-05-18T17:50:41.972Z" }, ] -[[package]] -name = "smmap" -version = "5.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, -] - [[package]] name = "soupsieve" version = "2.8.4" From ab9ca5c465375060483271cda3d4366b9cd1affd Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Mon, 15 Jun 2026 07:29:26 -0500 Subject: [PATCH 12/16] refactor(slackbot): more dependency cleanup --- apps/slackbot/pyproject.toml | 5 - apps/slackbot/scripts/cloudbuild.yaml | 42 -- apps/slackbot/scripts/requirements.txt | 190 ------- packages/db-python/f3_data_models/utils.py | 17 - uv.lock | 584 --------------------- 5 files changed, 838 deletions(-) delete mode 100644 apps/slackbot/scripts/cloudbuild.yaml delete mode 100644 apps/slackbot/scripts/requirements.txt diff --git a/apps/slackbot/pyproject.toml b/apps/slackbot/pyproject.toml index 6401b5a9..57b3db77 100644 --- a/apps/slackbot/pyproject.toml +++ b/apps/slackbot/pyproject.toml @@ -58,17 +58,12 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=8.4.2,<9.0.0", - "sqlalchemy-schemadisplay>=2.0,<3.0", - "graphviz>=0.20.3,<0.21.0", - "ipykernel>=6.30.1,<7.0.0", - "kaleido>=1.1.0,<2.0.0", "mplcyberpunk>=0.7.6,<0.8.0", "matplotlib>=3.10.7,<4.0.0", "playwright>=1.44.0,<2.0.0", "dataframe-image>=0.2.7,<0.3.0", "pandas>=2.2.2,<3.0.0", "debugpy>=1.8.17,<2.0.0", - "commitizen>=4.13.9,<5.0.0", ] [build-system] diff --git a/apps/slackbot/scripts/cloudbuild.yaml b/apps/slackbot/scripts/cloudbuild.yaml deleted file mode 100644 index 0b6bad96..00000000 --- a/apps/slackbot/scripts/cloudbuild.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# Legacy Cloud Build config retained temporarily during GitHub Actions deploy cutover. -# Primary deployment path is now tag-based workflows in .github/workflows/deploy-slackbot.yml. - -steps: - - name: gcr.io/cloud-builders/gcloud - id: prepare-context - entrypoint: bash - args: - - -c - - | - set -euo pipefail - rm -rf scripts_build_ctx - mkdir -p scripts_build_ctx - # Copy scripts package - cp -a scripts scripts_build_ctx/ - # Copy shared code needed by scripts (add/remove as needed) - [ -d features ] && cp -a features scripts_build_ctx/ || true - [ -d utilities ] && cp -a utilities scripts_build_ctx/ || true - [ -d application ] && cp -a application scripts_build_ctx/ || true - [ -d infrastructure ] && cp -a infrastructure scripts_build_ctx/ || true - [ -d common ] && cp -a common scripts_build_ctx/ || true - # Clean Python caches - find scripts_build_ctx -type d -name __pycache__ -prune -exec rm -rf {} + - find scripts_build_ctx -type f -name '*.pyc' -delete - - - name: gcr.io/cloud-builders/docker - id: build-image - args: - - build - - -t - - $_IMAGE - - -f - - scripts/Dockerfile - - scripts_build_ctx - -images: - - $_IMAGE -substitutions: - _IMAGE: us-central1-docker.pkg.dev/PROJECT/REPO/f3-bot-scripts:$COMMIT_SHA - -options: - logging: CLOUD_LOGGING_ONLY diff --git a/apps/slackbot/scripts/requirements.txt b/apps/slackbot/scripts/requirements.txt deleted file mode 100644 index fde267d0..00000000 --- a/apps/slackbot/scripts/requirements.txt +++ /dev/null @@ -1,190 +0,0 @@ -aiofiles==25.1.0 ; python_version >= "3.12" and python_version < "4.0" -aiohappyeyeballs==2.6.1 ; python_version >= "3.12" and python_version < "4.0" -aiohttp==3.13.5 ; python_version >= "3.12" and python_version < "4.0" -aiosignal==1.4.0 ; python_version >= "3.12" and python_version < "4.0" -alembic-postgresql-enum==1.10.0 ; python_version >= "3.12" and python_version < "4.0" -alembic==1.18.4 ; python_version >= "3.12" and python_version < "4.0" -annotated-types==0.7.0 ; python_version >= "3.12" and python_version < "4.0" -anyio==4.13.0 ; python_version >= "3.12" and python_version < "4.0" -appnope==0.1.4 ; python_version >= "3.12" and python_version < "4.0" and platform_system == "Darwin" -argcomplete==3.6.3 ; python_version >= "3.12" and python_version < "4.0" -asn1crypto==1.5.1 ; python_version >= "3.12" and python_version < "4.0" -asttokens==3.0.1 ; python_version >= "3.12" and python_version < "4.0" -attrs==26.1.0 ; python_version >= "3.12" and python_version < "4.0" -beautifulsoup4==4.14.3 ; python_version >= "3.12" and python_version < "4.0" -bleach==6.3.0 ; python_version >= "3.12" and python_version < "4.0" -blinker==1.9.0 ; python_version >= "3.12" and python_version < "4.0" -certifi==2026.4.22 ; python_version >= "3.12" and python_version < "4.0" -cffi==2.0.0 ; python_version >= "3.12" and python_version < "4.0" and (implementation_name == "pypy" or platform_python_implementation != "PyPy") -cfgv==3.5.0 ; python_version >= "3.12" and python_version < "4.0" -chardet==7.4.3 ; python_version >= "3.12" and python_version < "4.0" -charset-normalizer==3.4.7 ; python_version >= "3.12" and python_version < "4.0" -choreographer==1.3.0 ; python_version >= "3.12" and python_version < "4.0" -click-option-group==0.5.9 ; python_version >= "3.12" and python_version < "4.0" -click==8.1.8 ; python_version >= "3.12" and python_version < "4.0" -cloud-sql-python-connector==1.20.2 ; python_version >= "3.12" and python_version < "4.0" -cloudevents==1.12.0 ; python_version >= "3.12" and python_version < "4.0" -colorama==0.4.6 ; python_version >= "3.12" and python_version < "4.0" -comm==0.2.3 ; python_version >= "3.12" and python_version < "4.0" -commitizen==4.16.2 ; python_version >= "3.12" and python_version < "4.0" -contourpy==1.3.3 ; python_version >= "3.12" and python_version < "4.0" -cryptography==48.0.0 ; python_version >= "3.12" and python_version < "4.0" -cssselect==1.4.0 ; python_version >= "3.12" and python_version < "4.0" -cssutils==2.15.0 ; python_version >= "3.12" and python_version < "4.0" -cycler==0.12.1 ; python_version >= "3.12" and python_version < "4.0" -dataframe-image==0.2.7 ; python_version >= "3.12" and python_version < "4.0" -datetime==5.5 ; python_version >= "3.12" and python_version < "4.0" -debugpy==1.8.20 ; python_version >= "3.12" and python_version < "4.0" -decli==0.6.3 ; python_version >= "3.12" and python_version < "4.0" -decorator==5.2.1 ; python_version >= "3.12" and python_version < "4.0" -defusedxml==0.7.1 ; python_version >= "3.12" and python_version < "4.0" -deprecated==1.3.1 ; python_version >= "3.12" and python_version < "4.0" -deprecation==2.1.0 ; python_version >= "3.12" and python_version < "4.0" -distlib==0.4.0 ; python_version >= "3.12" and python_version < "4.0" -dnspython==2.8.0 ; python_version >= "3.12" and python_version < "4.0" -dotty-dict==1.3.1 ; python_version >= "3.12" and python_version < "4.0" -ecdsa==0.19.2 ; python_version >= "3.12" and python_version < "4.0" -encutils==1.0.0 ; python_version >= "3.12" and python_version < "4.0" -executing==2.2.1 ; python_version >= "3.12" and python_version < "4.0" -f3-data-models==1.0.9 ; python_version >= "3.12" and python_version < "4.0" -fastjsonschema==2.21.2 ; python_version >= "3.12" and python_version < "4.0" -filelock==3.29.0 ; python_version >= "3.12" and python_version < "4.0" -flask==3.1.3 ; python_version >= "3.12" and python_version < "4.0" -fonttools==4.63.0 ; python_version >= "3.12" and python_version < "4.0" -frozenlist==1.8.0 ; python_version >= "3.12" and python_version < "4.0" -functions-framework==3.10.1 ; python_version >= "3.12" and python_version < "4.0" -gitdb==4.0.12 ; python_version >= "3.12" and python_version < "4.0" -gitpython==3.1.50 ; python_version >= "3.12" and python_version < "4.0" -google-api-core==2.30.3 ; python_version >= "3.12" and python_version < "4.0" -google-auth==2.52.0 ; python_version >= "3.12" and python_version < "4.0" -google-cloud-appengine-logging==1.9.0 ; python_version >= "3.12" and python_version < "4.0" -google-cloud-audit-log==0.5.0 ; python_version >= "3.12" and python_version < "4.0" -google-cloud-core==2.6.0 ; python_version >= "3.12" and python_version < "4.0" -google-cloud-logging==3.15.0 ; python_version >= "3.12" and python_version < "4.0" -googleapis-common-protos==1.75.0 ; python_version >= "3.12" and python_version < "4.0" -graphviz==0.20.3 ; python_version >= "3.12" and python_version < "4.0" -greenlet==3.5.0 ; python_version >= "3.12" and python_version < "4.0" -grpc-google-iam-v1==0.14.4 ; python_version >= "3.12" and python_version < "4.0" -grpcio-status==1.80.0 ; python_version >= "3.12" and python_version < "4.0" -grpcio==1.80.0 ; python_version >= "3.12" and python_version < "4.0" -gunicorn==26.0.0 ; python_version >= "3.12" and python_version < "4.0" -h11==0.16.0 ; python_version >= "3.12" and python_version < "4.0" -identify==2.6.19 ; python_version >= "3.12" and python_version < "4.0" -idna==3.15 ; python_version >= "3.12" and python_version < "4.0" -importlib-metadata==8.7.1 ; python_version >= "3.12" and python_version < "4.0" -importlib-resources==6.5.2 ; python_version >= "3.12" and python_version < "4.0" -ipykernel==6.31.0 ; python_version >= "3.12" and python_version < "4.0" -ipython-pygments-lexers==1.1.1 ; python_version >= "3.12" and python_version < "4.0" -ipython==9.13.0 ; python_version >= "3.12" and python_version < "4.0" -itsdangerous==2.2.0 ; python_version >= "3.12" and python_version < "4.0" -jedi==0.20.0 ; python_version >= "3.12" and python_version < "4.0" -jinja2==3.1.6 ; python_version >= "3.12" and python_version < "4.0" -jsonschema-specifications==2025.9.1 ; python_version >= "3.12" and python_version < "4.0" -jsonschema==4.26.0 ; python_version >= "3.12" and python_version < "4.0" -jupyter-client==8.8.0 ; python_version >= "3.12" and python_version < "4.0" -jupyter-core==5.9.1 ; python_version >= "3.12" and python_version < "4.0" -jupyterlab-pygments==0.3.0 ; python_version >= "3.12" and python_version < "4.0" -kaleido==1.3.0 ; python_version >= "3.12" and python_version < "4.0" -kiwisolver==1.5.0 ; python_version >= "3.12" and python_version < "4.0" -logistro==2.0.1 ; python_version >= "3.12" and python_version < "4.0" -lxml==6.1.0 ; python_version >= "3.12" and python_version < "4.0" -mako==1.3.12 ; python_version >= "3.12" and python_version < "4.0" -markdown-it-py==4.2.0 ; python_version >= "3.12" and python_version < "4.0" -markupsafe==3.0.3 ; python_version >= "3.12" and python_version < "4.0" -matplotlib-inline==0.2.2 ; python_version >= "3.12" and python_version < "4.0" -matplotlib==3.10.9 ; python_version >= "3.12" and python_version < "4.0" -mdurl==0.1.2 ; python_version >= "3.12" and python_version < "4.0" -mistune==3.2.1 ; python_version >= "3.12" and python_version < "4.0" -more-itertools==11.0.2 ; python_version >= "3.12" and python_version < "4.0" -mplcyberpunk==0.7.6 ; python_version >= "3.12" and python_version < "4.0" -multidict==6.7.1 ; python_version >= "3.12" and python_version < "4.0" -nbclient==0.10.4 ; python_version >= "3.12" and python_version < "4.0" -nbconvert==7.17.1 ; python_version >= "3.12" and python_version < "4.0" -nbformat==5.10.4 ; python_version >= "3.12" and python_version < "4.0" -nest-asyncio==1.6.0 ; python_version >= "3.12" and python_version < "4.0" -nodeenv==1.10.0 ; python_version >= "3.12" and python_version < "4.0" -numpy==2.4.4 ; python_version >= "3.12" and python_version < "4.0" -oauthlib==3.3.1 ; python_version >= "3.12" and python_version < "4.0" -opentelemetry-api==1.41.1 ; python_version >= "3.12" and python_version < "4.0" -orjson==3.11.9 ; python_version >= "3.12" and python_version < "4.0" -packaging==26.2 ; python_version >= "3.12" and python_version < "4.0" -pandas==2.3.3 ; python_version >= "3.12" and python_version < "4.0" -pandocfilters==1.5.1 ; python_version >= "3.12" and python_version < "4.0" -parso==0.8.7 ; python_version >= "3.12" and python_version < "4.0" -pexpect==4.9.0 ; python_version >= "3.12" and python_version < "4.0" and sys_platform != "win32" and sys_platform != "emscripten" -pg8000==1.31.5 ; python_version >= "3.12" and python_version < "4.0" -pillow-heif==0.15.0 ; python_version >= "3.12" and python_version < "4.0" -pillow==12.2.0 ; python_version >= "3.12" and python_version < "4.0" -platformdirs==4.9.6 ; python_version >= "3.12" and python_version < "4.0" -playwright==1.59.0 ; python_version >= "3.12" and python_version < "4.0" -pre-commit==4.6.0 ; python_version >= "3.12" and python_version < "4.0" -prompt-toolkit==3.0.51 ; python_version >= "3.12" and python_version < "4.0" -propcache==0.5.2 ; python_version >= "3.12" and python_version < "4.0" -proto-plus==1.28.0 ; python_version >= "3.12" and python_version < "4.0" -protobuf==6.33.6 ; python_version >= "3.12" and python_version < "4.0" -psutil==7.2.2 ; python_version >= "3.12" and python_version < "4.0" -psycopg2-binary==2.9.12 ; python_version >= "3.12" and python_version < "4.0" -ptyprocess==0.7.0 ; python_version >= "3.12" and python_version < "4.0" and sys_platform != "win32" and sys_platform != "emscripten" -pure-eval==0.2.3 ; python_version >= "3.12" and python_version < "4.0" -pyasn1-modules==0.4.2 ; python_version >= "3.12" and python_version < "4.0" -pyasn1==0.6.3 ; python_version >= "3.12" and python_version < "4.0" -pycparser==3.0 ; python_version >= "3.12" and python_version < "4.0" and (platform_python_implementation != "PyPy" or implementation_name == "pypy") and implementation_name != "PyPy" -pydantic-core==2.46.4 ; python_version >= "3.12" and python_version < "4.0" -pydantic==2.13.4 ; python_version >= "3.12" and python_version < "4.0" -pydot==4.0.1 ; python_version >= "3.12" and python_version < "4.0" -pyee==13.0.1 ; python_version >= "3.12" and python_version < "4.0" -pygments==2.20.0 ; python_version >= "3.12" and python_version < "4.0" -pyparsing==3.3.2 ; python_version >= "3.12" and python_version < "4.0" -python-dateutil==2.9.0.post0 ; python_version >= "3.12" and python_version < "4.0" -python-discovery==1.3.1 ; python_version >= "3.12" and python_version < "4.0" -python-dotenv==1.2.2 ; python_version >= "3.12" and python_version < "4.0" -python-gitlab==6.5.0 ; python_version >= "3.12" and python_version < "4.0" -python-http-client==3.3.7 ; python_version >= "3.12" and python_version < "4.0" -pytz==2026.2 ; python_version >= "3.12" and python_version < "4.0" -pyyaml==6.0.3 ; python_version >= "3.12" and python_version < "4.0" -pyzmq==27.1.0 ; python_version >= "3.12" and python_version < "4.0" -questionary==2.1.1 ; python_version >= "3.12" and python_version < "4.0" -referencing==0.37.0 ; python_version >= "3.12" and python_version < "4.0" -requests-oauthlib==1.3.1 ; python_version >= "3.12" and python_version < "4.0" -requests-toolbelt==1.0.0 ; python_version >= "3.12" and python_version < "4.0" -requests==2.34.2 ; python_version >= "3.12" and python_version < "4.0" -rich==14.3.4 ; python_version >= "3.12" and python_version < "4.0" -rpds-py==0.30.0 ; python_version >= "3.12" and python_version < "4.0" -scramp==1.4.8 ; python_version >= "3.12" and python_version < "4.0" -sendgrid==6.12.4 ; python_version >= "3.12" and python_version < "4.0" -setuptools==82.0.1 ; python_version >= "3.12" and python_version < "4.0" -shellingham==1.5.4 ; python_version >= "3.12" and python_version < "4.0" -simplejson==4.1.1 ; python_version >= "3.12" and python_version < "4.0" -six==1.17.0 ; python_version >= "3.12" and python_version < "4.0" -slack-bolt==1.28.0 ; python_version >= "3.12" and python_version < "4.0" -slack-sdk==3.41.0 ; python_version >= "3.12" and python_version < "4.0" -smmap==5.0.3 ; python_version >= "3.12" and python_version < "4.0" -soupsieve==2.8.3 ; python_version >= "3.12" and python_version < "4.0" -sqlalchemy-citext==1.8.0 ; python_version >= "3.12" and python_version < "4.0" -sqlalchemy-schemadisplay==2.0 ; python_version >= "3.12" and python_version < "4.0" -sqlalchemy-utils==0.41.2 ; python_version >= "3.12" and python_version < "4.0" -sqlalchemy==2.0.49 ; python_version >= "3.12" and python_version < "4.0" -sqlmodel==0.0.22 ; python_version >= "3.12" and python_version < "4.0" -stack-data==0.6.3 ; python_version >= "3.12" and python_version < "4.0" -starlette==0.52.1 ; python_version >= "3.12" and python_version < "4.0" -termcolor==3.3.0 ; python_version >= "3.12" and python_version < "4.0" -tinycss2==1.4.0 ; python_version >= "3.12" and python_version < "4.0" -tomlkit==0.13.3 ; python_version >= "3.12" and python_version < "4.0" -tornado==6.5.5 ; python_version >= "3.12" and python_version < "4.0" -traitlets==5.15.0 ; python_version >= "3.12" and python_version < "4.0" -typing-extensions==4.15.0 ; python_version >= "3.12" and python_version < "4.0" -typing-inspection==0.4.2 ; python_version >= "3.12" and python_version < "4.0" -tzdata==2026.2 ; python_version >= "3.12" and python_version < "4.0" -urllib3==2.7.0 ; python_version >= "3.12" and python_version < "4.0" -uvicorn-worker==0.4.0 ; python_version >= "3.12" and python_version < "4.0" -uvicorn==0.47.0 ; python_version >= "3.12" and python_version < "4.0" -virtualenv==21.3.3 ; python_version >= "3.12" and python_version < "4.0" -watchdog==6.0.0 ; python_version >= "3.12" and python_version < "4.0" -watchfiles==1.1.1 ; python_version >= "3.12" and python_version < "4.0" -wcwidth==0.7.0 ; python_version >= "3.12" and python_version < "4.0" -webencodings==0.5.1 ; python_version >= "3.12" and python_version < "4.0" -werkzeug==3.1.8 ; python_version >= "3.12" and python_version < "4.0" -wrapt==2.1.2 ; python_version >= "3.12" and python_version < "4.0" -yarl==1.23.0 ; python_version >= "3.12" and python_version < "4.0" -zipp==3.23.1 ; python_version >= "3.12" and python_version < "4.0" -zope-interface==8.4 ; python_version >= "3.12" and python_version < "4.0" diff --git a/packages/db-python/f3_data_models/utils.py b/packages/db-python/f3_data_models/utils.py index b798882e..7e1446e8 100755 --- a/packages/db-python/f3_data_models/utils.py +++ b/packages/db-python/f3_data_models/utils.py @@ -375,20 +375,3 @@ def execute_sql_query(sql_query, backend: str | None = None): return records -def create_diagram(): - from pydot import Dot - from sqlalchemy_schemadisplay import create_schema_graph - - graph: Dot = create_schema_graph( - engine=get_engine(), - metadata=Base.metadata, - show_datatypes=True, - show_indexes=True, - rankdir="LR", - show_column_keys=True, - ) - graph.write_png("docs/_static/schema_diagram.png") - - -if __name__ == "__main__": - create_diagram() diff --git a/uv.lock b/uv.lock index 22cdd9e9..9ee73068 100644 --- a/uv.lock +++ b/uv.lock @@ -167,24 +167,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] -[[package]] -name = "appnope" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, -] - -[[package]] -name = "argcomplete" -version = "3.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, -] - [[package]] name = "asn1crypto" version = "1.5.1" @@ -194,15 +176,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, ] -[[package]] -name = "asttokens" -version = "3.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, -] - [[package]] name = "attrs" version = "26.1.0" @@ -430,20 +403,6 @@ wheels = [ { 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 = "choreographer" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "logistro" }, - { name = "platformdirs" }, - { name = "simplejson" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/17/69/3058cd4f16d6b75c80e8f95e5b713d930526353ce294df9a7887453ba215/choreographer-1.3.0.tar.gz", hash = "sha256:6c44a0e48e9b37977344d40bfa5a9ed88575fe4bc0fd836771bf702bc24d6884", size = 48291, upload-time = "2026-04-28T22:57:45.114Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/6c/ff8bf52315064dbeb55cb5067e191120a5b2e58bb648d0d34cf7969dc2c2/choreographer-1.3.0-py3-none-any.whl", hash = "sha256:cea4cb739e4f61625e4b53888a8d3fa1d3bf73948b56753e460ab44da7d8d44f", size = 52622, upload-time = "2026-04-28T22:57:44.015Z" }, -] - [[package]] name = "click" version = "8.1.8" @@ -494,38 +453,6 @@ 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 = "comm" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, -] - -[[package]] -name = "commitizen" -version = "4.16.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "argcomplete" }, - { name = "charset-normalizer" }, - { name = "colorama" }, - { name = "decli" }, - { name = "deprecated" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "prompt-toolkit" }, - { name = "pyyaml" }, - { name = "questionary" }, - { name = "termcolor" }, - { name = "tomlkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/17/cc/d87b094ef858c67febcd1d8902352c84b42c9ebc8221d6f2e9d553273358/commitizen-4.16.3.tar.gz", hash = "sha256:5cdca4c02715cc770312f4b505c65a6c39024c73ece41b943bccaf81c44436ed", size = 66772, upload-time = "2026-05-30T06:34:21.247Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/35/c7995b1e66159193dd31ed5628d59acbaf4611811645eedf0fb2d5a91946/commitizen-4.16.3-py3-none-any.whl", hash = "sha256:ce1be39fe98a16725fd0c960daf0f360acac86db7ae8db1e1df8d3541005b5be", size = 88927, upload-time = "2026-05-30T06:34:20.006Z" }, -] - [[package]] name = "contourpy" version = "1.3.3" @@ -732,24 +659,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, ] -[[package]] -name = "decli" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/59/d4ffff1dee2c8f6f2dd8f87010962e60f7b7847504d765c91ede5a466730/decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656", size = 7564, upload-time = "2025-06-01T15:23:41.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989, upload-time = "2025-06-01T15:23:40.228Z" }, -] - -[[package]] -name = "decorator" -version = "5.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, -] - [[package]] name = "defusedxml" version = "0.7.1" @@ -759,18 +668,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] -[[package]] -name = "deprecated" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, -] - [[package]] name = "deprecation" version = "2.1.0" @@ -813,15 +710,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/cb/27d1c167d7b6607316c0c4ec5869b256104eb2c9607f76ef2ffa10806d3e/encutils-1.0.0-py3-none-any.whl", hash = "sha256:605297da19a23d1b2da7d3b9bd75513acc979e9facf03aa7ec7ba04b5f567a79", size = 21231, upload-time = "2026-04-19T16:27:18.778Z" }, ] -[[package]] -name = "executing" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, -] - [[package]] name = "f3-data-models" version = "1.1.0" @@ -888,18 +776,13 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "commitizen" }, { name = "dataframe-image" }, { name = "debugpy" }, - { name = "graphviz" }, - { name = "ipykernel" }, - { name = "kaleido" }, { name = "matplotlib" }, { name = "mplcyberpunk" }, { name = "pandas" }, { name = "playwright" }, { name = "pytest" }, - { name = "sqlalchemy-schemadisplay" }, ] [package.metadata] @@ -927,18 +810,13 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "commitizen", specifier = ">=4.13.9,<5.0.0" }, { name = "dataframe-image", specifier = ">=0.2.7,<0.3.0" }, { name = "debugpy", specifier = ">=1.8.17,<2.0.0" }, - { name = "graphviz", specifier = ">=0.20.3,<0.21.0" }, - { name = "ipykernel", specifier = ">=6.30.1,<7.0.0" }, - { name = "kaleido", specifier = ">=1.1.0,<2.0.0" }, { name = "matplotlib", specifier = ">=3.10.7,<4.0.0" }, { name = "mplcyberpunk", specifier = ">=0.7.6,<0.8.0" }, { name = "pandas", specifier = ">=2.2.2,<3.0.0" }, { name = "playwright", specifier = ">=1.44.0,<2.0.0" }, { name = "pytest", specifier = ">=8.4.2,<9.0.0" }, - { name = "sqlalchemy-schemadisplay", specifier = ">=2.0,<3.0" }, ] [[package]] @@ -1294,15 +1172,6 @@ grpc = [ { name = "grpcio" }, ] -[[package]] -name = "graphviz" -version = "0.20.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/83/5a40d19b8347f017e417710907f824915fba411a9befd092e52746b63e9f/graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d", size = 256455, upload-time = "2024-03-21T07:50:45.772Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/be/d59db2d1d52697c6adc9eacaf50e8965b6345cc143f671e1ed068818d5cf/graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5", size = 47126, upload-time = "2024-03-21T07:50:43.091Z" }, -] - [[package]] name = "greenlet" version = "3.5.1" @@ -1487,64 +1356,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "ipykernel" -version = "6.31.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython" }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a5/1d/d5ba6edbfe6fae4c3105bca3a9c889563cc752c7f2de45e333164c7f4846/ipykernel-6.31.0.tar.gz", hash = "sha256:2372ce8bc1ff4f34e58cafed3a0feb2194b91fc7cad0fc72e79e47b45ee9e8f6", size = 167493, upload-time = "2025-10-20T11:42:39.948Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/d8/502954a4ec0efcf264f99b65b41c3c54e65a647d9f0d6f62cd02227d242c/ipykernel-6.31.0-py3-none-any.whl", hash = "sha256:abe5386f6ced727a70e0eb0cf1da801fa7c5fa6ff82147747d5a0406cd8c94af", size = 117003, upload-time = "2025-10-20T11:42:37.502Z" }, -] - -[[package]] -name = "ipython" -version = "9.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "psutil", marker = "sys_platform != 'emscripten'" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e2/23/3a27530575643c8bb7bfc757a28e2e7ef80092afbf59a2bc5716320b6602/ipython-9.14.1.tar.gz", hash = "sha256:f913bf74df06d458e46ced84ca506c23797590d594b236fe60b14df213291e7b", size = 4433457, upload-time = "2026-06-05T08:12:34.921Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/22/58818a63eaf8982b67632b1bc20585c811611b15a8da19d6012323dc76a5/ipython-9.14.1-py3-none-any.whl", hash = "sha256:5d4a9ecaa3b10e6e5f269dd0948bdb58ca9cb851899cd23e07c320d3eb11613c", size = 627770, upload-time = "2026-06-05T08:12:33.045Z" }, -] - -[[package]] -name = "ipython-pygments-lexers" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, -] - [[package]] name = "itsdangerous" version = "2.2.0" @@ -1554,18 +1365,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] -[[package]] -name = "jedi" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "parso" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -1644,21 +1443,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, ] -[[package]] -name = "kaleido" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "choreographer" }, - { name = "logistro" }, - { name = "orjson" }, - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e0/64/53eac73d31dbfc3310ee2e87bcac1ae7417427f0fbe3dd800eaf676db324/kaleido-1.3.0.tar.gz", hash = "sha256:5e0378a7475e98852773deeb6483dee91f8aa7b364dde7b5f2b3622cb468a3e6", size = 68938, upload-time = "2026-05-04T19:45:28.932Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/b9/a6d8bb7d228940f01885bd9f327ab7f9d366a9be775c4bf366bf9d9477ae/kaleido-1.3.0-py3-none-any.whl", hash = "sha256:52714dfd38e8f2a114831826200c40bb10d0ca0c11d4272f3f48ad499cd8f8ea", size = 55580, upload-time = "2026-05-04T19:45:27.483Z" }, -] - [[package]] name = "kiwisolver" version = "1.5.0" @@ -1745,15 +1529,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, ] -[[package]] -name = "logistro" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/08/90/bfd7a6fab22bdfafe48ed3c4831713cb77b4779d18ade5e248d5dbc0ca22/logistro-2.0.1.tar.gz", hash = "sha256:8446affc82bab2577eb02bfcbcae196ae03129287557287b6a070f70c1985047", size = 8398, upload-time = "2025-11-01T02:41:18.81Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/6aa79ba3570bddd1bf7e951c6123f806751e58e8cce736bad77b2cf348d7/logistro-2.0.1-py3-none-any.whl", hash = "sha256:06ffa127b9fb4ac8b1972ae6b2a9d7fde57598bf5939cd708f43ec5bba2d31eb", size = 8555, upload-time = "2025-11-01T02:41:17.587Z" }, -] - [[package]] name = "lxml" version = "6.1.1" @@ -1951,18 +1726,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, ] -[[package]] -name = "matplotlib-inline" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, -] - [[package]] name = "mistune" version = "3.2.1" @@ -2148,15 +1911,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, ] -[[package]] -name = "nest-asyncio" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, -] - [[package]] name = "nodeenv" version = "1.10.0" @@ -2248,59 +2002,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" }, ] -[[package]] -name = "orjson" -version = "3.11.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, - { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, - { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, - { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, - { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, - { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, - { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, - { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, - { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, - { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, - { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, - { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, - { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, - { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, - { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, - { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, - { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, - { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, - { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, - { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, - { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, - { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, - { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, - { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, - { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, - { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, - { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, - { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, - { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, - { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, - { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, - { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, - { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, - { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, -] - [[package]] name = "packaging" version = "26.2" @@ -2366,15 +2067,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, ] -[[package]] -name = "parso" -version = "0.8.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, -] - [[package]] name = "pastel" version = "0.2.1" @@ -2384,18 +2076,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, ] -[[package]] -name = "pexpect" -version = "4.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ptyprocess" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, -] - [[package]] name = "pg8000" version = "1.31.5" @@ -2562,18 +2242,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] -[[package]] -name = "prompt-toolkit" -version = "3.0.51" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wcwidth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, -] - [[package]] name = "propcache" version = "0.5.2" @@ -2695,34 +2363,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0", size = 171659, upload-time = "2026-05-19T23:02:27.842Z" }, ] -[[package]] -name = "psutil" -version = "7.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, - { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, - { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, - { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, - { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, - { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, - { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, - { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, -] - [[package]] name = "psycopg2-binary" version = "2.9.12" @@ -2764,24 +2404,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/be/b732c8418ffa5bcfda002890f5dc4c869fc17db66ff11f53b17cfe44afc0/psycopg2_binary-2.9.12-cp314-cp314-win_amd64.whl", hash = "sha256:f12ae41fcafadb39b2785e64a40f9db05d6de2ac114077457e0e7c597f3af980", size = 2848762, upload-time = "2026-04-20T23:35:46.421Z" }, ] -[[package]] -name = "ptyprocess" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, -] - -[[package]] -name = "pure-eval" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, -] - [[package]] name = "pyasn1" version = "0.6.3" @@ -2902,18 +2524,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, ] -[[package]] -name = "pydot" -version = "4.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, -] - [[package]] name = "pyee" version = "13.0.1" @@ -3101,18 +2711,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, ] -[[package]] -name = "questionary" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "prompt-toolkit" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, -] - [[package]] name = "referencing" version = "0.37.0" @@ -3291,68 +2889,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/55/b3c3880a77082e8f7374954e0074aafafaa9bc78bdf9c8f5a92c2e7afc6a/sendgrid-6.12.5-py3-none-any.whl", hash = "sha256:96f92cc91634bf552fdb766b904bbb53968018da7ae41fdac4d1090dc0311ca8", size = 102173, upload-time = "2025-09-19T06:23:07.93Z" }, ] -[[package]] -name = "setuptools" -version = "82.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, -] - -[[package]] -name = "simplejson" -version = "4.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0e/2a/54837395a3487c725669428d513293612a48d82b95a0642c936932e5d898/simplejson-4.1.1.tar.gz", hash = "sha256:c08eb9f7a90f77ae470e19a07472e9a79ebc0d1c2315d86a72767665bd5ba79f", size = 118860, upload-time = "2026-04-24T19:24:59.819Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/25/e90998fe8e480eb43b966c09e835379887d427567ebd496563d3b1e16b19/simplejson-4.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:19040a17154dc03d289bab68d73ce0a6a0be01de30c584bbdd93490bead14b22", size = 112414, upload-time = "2026-04-24T19:23:06.084Z" }, - { url = "https://files.pythonhosted.org/packages/9c/a0/abd4785f36c3400f1fbb21f517be39295a750a714f04b7ee175adf6ef580/simplejson-4.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a94ebaecdbaa80d9551a3ec6bf0c9302fc8b53ab6c1b2bfd498a1df4cb28158d", size = 91120, upload-time = "2026-04-24T19:23:07.877Z" }, - { url = "https://files.pythonhosted.org/packages/b8/78/fc060d2e3b13c6ec59288574b8efac64075e316b2afba4396a56b2422f78/simplejson-4.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:67341c95c0a168ab4a6d1e807e50463f1c8da932c3286d81e201266c427061fa", size = 91055, upload-time = "2026-04-24T19:23:09.264Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b6/156a8de1e1b47694f0e7de6675866936608d45dc68388fd017d36f8693be/simplejson-4.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45ec18e337fec538b7e902d489505c450b2454653d1290f3f50385e6fd8aa607", size = 190297, upload-time = "2026-04-24T19:23:11.226Z" }, - { url = "https://files.pythonhosted.org/packages/86/1c/e4d0eab695be3eb21d0f46bce820752031f03e7113f9c80a9b3c73ee7157/simplejson-4.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:820c69a4710400e9b248d5670647d60be58824369282d3925e516b3ff1a7cd82", size = 187002, upload-time = "2026-04-24T19:23:12.982Z" }, - { url = "https://files.pythonhosted.org/packages/76/0e/7f5a59d29426b062d5928fb88b403c3f797129d53be7102f955dbe51aa44/simplejson-4.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e708d373a10e4378ef2d59f8361850c7150fd907ed49efe49bc5492160476d1", size = 195146, upload-time = "2026-04-24T19:23:14.517Z" }, - { url = "https://files.pythonhosted.org/packages/78/18/9943db224dd4d5fa3c090c3e56a94c37b254338c83995ec5680285111c40/simplejson-4.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:980fc33353f81fd12d8c49d44f8c2760d1dc8192285e627c5180d141035b228a", size = 183931, upload-time = "2026-04-24T19:23:16.742Z" }, - { url = "https://files.pythonhosted.org/packages/c2/08/9a690da9a766161c06c627d805362cf159f1abe480969372b2897649b955/simplejson-4.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de2ed102fff88dacf543699f53ee3a533cc11539a39baa176b7e09dd783069d6", size = 192228, upload-time = "2026-04-24T19:23:18.33Z" }, - { url = "https://files.pythonhosted.org/packages/05/88/bd8aad36b451ffb0e0a3f721d695a88befa6d1ac7d1e02ae788ca7ff4029/simplejson-4.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785ff8edc0e28bf773a32543a6bbed46351453c997b3f6709c744e3c2f7eabb", size = 187808, upload-time = "2026-04-24T19:23:21.165Z" }, - { url = "https://files.pythonhosted.org/packages/04/ee/14f91db0d1f481533b651dafbf8cd0da088d9817f7af30c68f7f19f9c847/simplejson-4.1.1-cp312-cp312-win32.whl", hash = "sha256:2e0d5ead6d14610467ec356ec1f6b5d8a56aa216abaad8d41c8b873b16cf313f", size = 88512, upload-time = "2026-04-24T19:23:22.764Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c4/90de06b2d8737c68c05ff9274113f854dbf6a5f28b7a955212111672cb57/simplejson-4.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:63a5451f557d6be48a231bae932458655c620902b868170b2f1c8afed496f6b4", size = 90748, upload-time = "2026-04-24T19:23:24.494Z" }, - { url = "https://files.pythonhosted.org/packages/37/a9/47b445eeb559c9593453a0648e0fd6d08e8adff64dd5e5ced66726da8a09/simplejson-4.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dff52fc7af272e84fc21cc5a06c927c823ca6ae00af14f3b0d7707b42775ed98", size = 113160, upload-time = "2026-04-24T19:23:26.033Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/cb72db31523c164dea5dc55b02dad065a40c478856bc7534b279d2b51906/simplejson-4.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:971aed0647ad6e840a3943bec812fcda5f2d26a5497a4981d1fb49aa4f9a396c", size = 91521, upload-time = "2026-04-24T19:23:27.572Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e5/54cb7c50ad5fdc1e0a86b7df4b135c2cbd5c4623605aa94466659098e8da/simplejson-4.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:249e2e220aa6d9b9d936bde84eb7bf79d5b6c5a8273c6e411f8b1635a9073f2d", size = 91407, upload-time = "2026-04-24T19:23:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/38/2e/21a3ede87f0bf82d6c7bcb90480d50a6490eb974c6ab20881188e440957c/simplejson-4.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e5cdd6a5d52299f345c15ab5678cc4249e24f383f361d986afbc3c7072a6b6b", size = 192451, upload-time = "2026-04-24T19:23:30.56Z" }, - { url = "https://files.pythonhosted.org/packages/59/df/9903edd3102bf0b5984edfcb90c88612330996efa3b4fbf8a971d6e17839/simplejson-4.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642cec364e0676e2d5a73fa4d31d0c7c55886997caa2fde24e8292ca44d32728", size = 189015, upload-time = "2026-04-24T19:23:32.647Z" }, - { url = "https://files.pythonhosted.org/packages/98/cd/33230927a780e1398b857e3944abb914556994d252b1d765ae40d112cb25/simplejson-4.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:76fe296ca1df23d290033f10aaacf534fd1b3e3007e7f9ff8aa68b21413aaa78", size = 196658, upload-time = "2026-04-24T19:23:34.563Z" }, - { url = "https://files.pythonhosted.org/packages/cd/84/2c5a7444eb53e9a86d3738299bffddd9f53aeed799ded2f45368221fdb19/simplejson-4.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f0ad25b7dc4e0fb23858355819f2e994f1a5badcdcde8737eac7921c2f1ed2a", size = 185967, upload-time = "2026-04-24T19:23:36.191Z" }, - { url = "https://files.pythonhosted.org/packages/d3/68/454378e06d059cd412a7ed5d87fb6d29fd5b60f13a4d89fc1f764ff434df/simplejson-4.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a59ebd0533f03fd06ff0c42ba0f02d93cbcdd7944922bf3b93911327a95b901f", size = 193940, upload-time = "2026-04-24T19:23:38.151Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d5/a15bf915f623a2c5a079d6e3be8256fdb8ef06f110669493a09b9d6933e0/simplejson-4.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bccbf4419676b517939852e5aeff2af6aee4dc046881c67a1581fa6f1cb01abd", size = 189795, upload-time = "2026-04-24T19:23:40.139Z" }, - { url = "https://files.pythonhosted.org/packages/d2/c9/37212ae7dc4b607f0978c408e8633f05c810884e054c33113184c6c2c8a2/simplejson-4.1.1-cp313-cp313-win32.whl", hash = "sha256:6c845363eb5fd166fb7c72243da38f4fcfde666ede7fdf2cc6fd7762894626f7", size = 88773, upload-time = "2026-04-24T19:23:41.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a5/c7a0a47883a9015b54c9d8a4b62f2aba17bd4335b1787b9b8a0fc2fa6d52/simplejson-4.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:104d8324c34f25b4b90800bc5fa363780cbc3d8496aef061cba7ce1af9162270", size = 90888, upload-time = "2026-04-24T19:23:43.11Z" }, - { url = "https://files.pythonhosted.org/packages/d3/18/4a118a6a92eb33bb08c8e2fe7ec85cb96f0673491bb2b829930831ee4fbe/simplejson-4.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ed7473602b6625de793b6acba49aa949f144a475f538792067e4cf2fda2071f5", size = 110492, upload-time = "2026-04-24T19:23:44.957Z" }, - { url = "https://files.pythonhosted.org/packages/07/f4/84d160e9fa8cada1e0a9381cae4fa81eecd573577a5b34366d8ced59bdf7/simplejson-4.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:225c9caa324c5b554d009fb9cac22aee7711e71bd96f487938c659af467e828e", size = 90152, upload-time = "2026-04-24T19:23:46.355Z" }, - { url = "https://files.pythonhosted.org/packages/68/31/9a5432c433a7671107182cdc9a20ea78a70f99c4e5334aa54b6d4d0d79ed/simplejson-4.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:95407269340c7f22f09776ea7b717a52cf56cfcf119b5e45f66faa4a26445bea", size = 90115, upload-time = "2026-04-24T19:23:47.743Z" }, - { url = "https://files.pythonhosted.org/packages/78/91/3635cdb13318cb0a328abaa69e2b91251caad39d6779aa308098f341f6cb/simplejson-4.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3851658d642c1184d2023f0e6c9ce44a21eb1629e74e7c84ef956b128841fe12", size = 184036, upload-time = "2026-04-24T19:23:49.472Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/149b6ec5393f6849d98c59cadba888b710a8ef4b805ab91e11a566960d40/simplejson-4.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95a3bb0f78e85f4937f99092239f2011ce06f0f2d803df5c299cc05abbeae008", size = 180543, upload-time = "2026-04-24T19:23:51.023Z" }, - { url = "https://files.pythonhosted.org/packages/df/7c/a5d968d0b527a748b667e62bea94309ccbcb1e2b108e8f0cf8547efaa12b/simplejson-4.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbfdaa7c0603f75b7b14b211b7f2be44696d4e26833ad2d91d5c87bf5fb9a920", size = 188725, upload-time = "2026-04-24T19:23:52.995Z" }, - { url = "https://files.pythonhosted.org/packages/db/e3/6a8d11181d587ef00e2db9112357e6832111e56dd56b01b5c11758a1965d/simplejson-4.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e3c584071dced8c21b4689f0254303521daeb9b5bc1f4289755d71fa3cb0d3", size = 177492, upload-time = "2026-04-24T19:23:54.581Z" }, - { url = "https://files.pythonhosted.org/packages/67/e3/8b0eb8b06e8198cfbd1270487da163d0093df05cc4f557350cd65e2f7e79/simplejson-4.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:036a27bd0469b9d79557cbddb392969f876cd7f278cfbd0fba81534927a06575", size = 185281, upload-time = "2026-04-24T19:23:56.13Z" }, - { url = "https://files.pythonhosted.org/packages/dc/5f/64990f07ec9e2cb1a814c674e2e21b5693207f74ac70eb72151b847ea4e6/simplejson-4.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b70bfd2f67f3351baba08aa3ae9233c83f21fd95ae5e6b3d0ecb8c647929112f", size = 181848, upload-time = "2026-04-24T19:23:57.92Z" }, - { url = "https://files.pythonhosted.org/packages/61/a5/bbc1bc0447f339f79f99ab8c37f7f037cb2f1f93af75d6a4d553096bb0c3/simplejson-4.1.1-cp314-cp314-win32.whl", hash = "sha256:37233c72ce88d06acb92747347742b3c07871eba6789f060c179c9302dde8efe", size = 88761, upload-time = "2026-04-24T19:23:59.397Z" }, - { url = "https://files.pythonhosted.org/packages/18/72/ec1b5cbdcb140c132e6c7bdf99bd73e4f675439e77126c88f472fcffa09c/simplejson-4.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:cc0442dea71cd9cbf30a0b8b9929ab5aa6c02c0443a3d977351e6ec5bada4388", size = 91018, upload-time = "2026-04-24T19:24:00.85Z" }, - { url = "https://files.pythonhosted.org/packages/3d/97/4fa437f68ff72219bac3bf3d050de9c6265691f3a170e16954bd69d7cddd/simplejson-4.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:c996a4d38290c515af347740659ce095b425449c164a5c9fa3977caa6eff5dbe", size = 113919, upload-time = "2026-04-24T19:24:02.287Z" }, - { url = "https://files.pythonhosted.org/packages/c2/83/59de041d09eb4a9577f7015d7263c32095dfb7fde49717dff62145d89809/simplejson-4.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c65c763fb20d7ca113c1c14dce2fc04a0fc3a57aceff533d6fdac707c7bffb40", size = 91904, upload-time = "2026-04-24T19:24:03.812Z" }, - { url = "https://files.pythonhosted.org/packages/03/8e/46bb345d540f6eb31427d984a4e518cdb182d0621814fee4fee045e8815b/simplejson-4.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0da5c9f57206ee7ef280ff7f1d924937b0a64f9a271a5ef371a2ecdbebba7421", size = 91752, upload-time = "2026-04-24T19:24:05.622Z" }, - { url = "https://files.pythonhosted.org/packages/83/e2/1b2ce97f068835eb3d253c116a4df7a3f436b7bf2fb5ff1ba29287e8b0ec/simplejson-4.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ea3426e786425d10e9e82f8a6eda74a7d6eb10d99165ac3d0d3bbcb65c0ea343", size = 214021, upload-time = "2026-04-24T19:24:07.447Z" }, - { url = "https://files.pythonhosted.org/packages/48/70/d93e556df6a0786298644a7c08304fcbeddc248325f23f38acbebeb21165/simplejson-4.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d75cea7a1025edd7e439b2966b3d977c45b5b899e2adaf422811b3ac702ed9fb", size = 213530, upload-time = "2026-04-24T19:24:09.289Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a5/c93bf305b9f00d7259e09e713d60e75bd0f7f53da970f716ab90491770e7/simplejson-4.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63c2ada8e58f266491f19eed2eeeb7c25c6141e52f8f9e820f6bb94156cf8dbc", size = 218282, upload-time = "2026-04-24T19:24:10.991Z" }, - { url = "https://files.pythonhosted.org/packages/0c/20/a9b5d2e27ec44b069ee251bd55544fc76929a067107b1050001566ba86f3/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d1fffb56305c5b475ee746cf9e04f97423ba5aaacd292dc1255bd75b1d3b124b", size = 209249, upload-time = "2026-04-24T19:24:12.662Z" }, - { url = "https://files.pythonhosted.org/packages/97/e4/e06ee682ed5df67592181f5ecb062e35878967e27f5b6e087237d4548d95/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a6525ec733f43d0541206cffa64fd2aad5a7ae3eb76566aff49cd4db6382209a", size = 213963, upload-time = "2026-04-24T19:24:14.302Z" }, - { url = "https://files.pythonhosted.org/packages/9c/9f/1e160e4cd8cdbf062bf6a454cdf814dc7a48eb47e566fdb8f80ccb202605/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:861e393260508efa64d8805a8e49c416c3484907e3f146ce966c69552b49b9a3", size = 210474, upload-time = "2026-04-24T19:24:15.917Z" }, - { url = "https://files.pythonhosted.org/packages/7a/e6/cecd913df322df5bbe7ebb8ba39e0708e505a165553900da8a7761026d6f/simplejson-4.1.1-cp314-cp314t-win32.whl", hash = "sha256:d083b89d30948a751d3d97476c2ed91e4caaa24a1a1459bdbadb8876242c71fe", size = 91134, upload-time = "2026-04-24T19:24:17.635Z" }, - { url = "https://files.pythonhosted.org/packages/97/73/f540dde99cc1d393bd062ab3b5735b777561a5d8f8a5f2e241164444d77a/simplejson-4.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4cbb299d0528ec0447fe366d8c9641860e28f997a62730690fef905f1f41046e", size = 94467, upload-time = "2026-04-24T19:24:19.109Z" }, - { url = "https://files.pythonhosted.org/packages/ce/6a/8b74c52ffd33dbbde00fe7251fee6a0acdc8cea33f7a43805aed258fb79b/simplejson-4.1.1-py3-none-any.whl", hash = "sha256:2ce92b3748f02423e26d2bfb636fb9d7a8f67c8f5854dcae69d350d123b2eee2", size = 69195, upload-time = "2026-04-24T19:24:57.962Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -3458,21 +2994,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/1a/c3/404caceaffdf0dcfa822e8b068aebc4fef328bf85af42b6cd8fdd2b2555b/sqlalchemy-citext-1.8.0.tar.gz", hash = "sha256:a1740e693a9a334e7c8f60ae731083fe75ce6c1605bb9ca6644a6f1f63b15b77", size = 3601, upload-time = "2021-03-02T18:14:03.539Z" } -[[package]] -name = "sqlalchemy-schemadisplay" -version = "2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pillow" }, - { name = "pydot" }, - { name = "setuptools" }, - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4f/b0/d4587a6223dd563072ed5d0b94e0d062bb3d019bf16d0e65a85324c49efc/sqlalchemy_schemadisplay-2.0.tar.gz", hash = "sha256:e90b9c9868814975d674a889aadb7c4651658f0e119e1c9320279ea527744d5e", size = 11637, upload-time = "2024-02-15T11:52:53.355Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/9e/a8d5cea8fb842393846ad42596eeb989511fab0aa1c88fe226c24c6355e7/sqlalchemy_schemadisplay-2.0-py3-none-any.whl", hash = "sha256:e4b928e2aec145f72a2b35de7855a78fca5e09ac4d48f2d58b4472cb640cd362", size = 11377, upload-time = "2024-02-15T11:52:52.21Z" }, -] - [[package]] name = "sqlalchemy-utils" version = "0.41.2" @@ -3498,20 +3019,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/b1/3af5104b716c420e40a6ea1b09886cae3a1b9f4538343875f637755cae5b/sqlmodel-0.0.22-py3-none-any.whl", hash = "sha256:a1ed13e28a1f4057cbf4ff6cdb4fc09e85702621d3259ba17b3c230bfb2f941b", size = 28276, upload-time = "2024-08-31T09:43:22.358Z" }, ] -[[package]] -name = "stack-data" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "asttokens" }, - { name = "executing" }, - { name = "pure-eval" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, -] - [[package]] name = "starlette" version = "0.52.1" @@ -3525,15 +3032,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] -[[package]] -name = "termcolor" -version = "3.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, -] - [[package]] name = "tinycss2" version = "1.5.1" @@ -3546,15 +3044,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, ] -[[package]] -name = "tomlkit" -version = "0.13.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, -] - [[package]] name = "tornado" version = "6.5.7" @@ -3771,15 +3260,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, ] -[[package]] -name = "wcwidth" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" }, -] - [[package]] name = "webencodings" version = "0.5.1" @@ -3801,70 +3281,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, ] -[[package]] -name = "wrapt" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/0c/bfae7b9401583b6d05938cd16dedc43857d96da2f8a3d50d78cc515bf6ff/wrapt-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3ffad790d9d11d8ecf9f17c4bb671a5b4089e4d8b575c46c5129597f41f836b0", size = 81021, upload-time = "2026-05-22T14:48:00.313Z" }, - { url = "https://files.pythonhosted.org/packages/26/58/80f6a6599f933f4caecc1cb3ee88a04faf81e8b9bddbd6109c688dd63e0f/wrapt-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:628f5220c7a904d5fc78f7075c8d7871433eb6d035c94728a22fdf85f193d2a8", size = 81692, upload-time = "2026-05-22T14:48:01.49Z" }, - { url = "https://files.pythonhosted.org/packages/17/93/fb357cc7847c58a8ae790be718903afa81a28d23e642c843dc4129e8a0b2/wrapt-2.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:61acce4257a9883669703c525447c5b4c392edf0f987ae77ec32668440158f0e", size = 169364, upload-time = "2026-05-22T14:48:02.791Z" }, - { url = "https://files.pythonhosted.org/packages/aa/0b/76b601ee309a8bd556af0eecb184394c20b3c49aa9c8e085aa1ffacc2568/wrapt-2.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727ab4244622cd6ad2390f322642090c877d2e83a608d2653a7643ae5368d926", size = 171079, upload-time = "2026-05-22T14:48:04.22Z" }, - { url = "https://files.pythonhosted.org/packages/cd/87/ee3f32d5658e3e26d3e0e457922b47a36dd3bfbdfee7f97bb3e802344a66/wrapt-2.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03df9ebed4c73ab93fa8c07e3d41d818dfca1852b15731a3de59457b27814624", size = 160205, upload-time = "2026-05-22T14:48:05.553Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d0/ae2fd64277a67f5d7bffcf2d05eea1e476263fb2a072baf0b0129ab85984/wrapt-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0d9ff006f420b2ec8296aa56ade43ea7da3e997e85769f0aafc5e0661aacb710", size = 168922, upload-time = "2026-05-22T14:48:07.132Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f3/2d541a060c5bbafb9400bca4917e4d78bfd1f239f404782c86831a8f6b29/wrapt-2.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:844c858fc3bb7eacc0ba8efa904935d16aac6a4470948ad1e7e55c9f5a2a665f", size = 158388, upload-time = "2026-05-22T14:48:08.629Z" }, - { url = "https://files.pythonhosted.org/packages/1d/68/8d92c8800c57e93cb116ae9e9d6cbafc34fade5ee9f9107b6f203fb4dc35/wrapt-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87bacdaf225117a342a20d9c03438d701c02112f6e3f351ce9b7f32354f14797", size = 167682, upload-time = "2026-05-22T14:48:10.042Z" }, - { url = "https://files.pythonhosted.org/packages/30/72/83ea3790ea352439442349388e29ff07b76e0686265f9088bbb505d1608d/wrapt-2.2.1-cp312-cp312-win32.whl", hash = "sha256:2f8c90c8afde51969487be4e1343ae049b268854877d415c2510baf833775052", size = 77857, upload-time = "2026-05-22T14:48:11.782Z" }, - { url = "https://files.pythonhosted.org/packages/ef/cb/99450668dd3502d62a54a1c8aa56e44f34cb8c1261b381cfe2e7926c3b75/wrapt-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ce32763ac31ce94fe9aada947e479b1975012bff166da409b4b9e4e376cf7e5", size = 80825, upload-time = "2026-05-22T14:48:13.046Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3a/87512881be64e743f9ee4c66f4cbe8e884974bef2a5989af71f999653ac7/wrapt-2.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d1b4d0e0c2119587a31f5c029abd547e0c81d93b89d394566fe1588659eb579", size = 79087, upload-time = "2026-05-22T14:48:14.323Z" }, - { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, - { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, - { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, - { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, - { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, - { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, - { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, - { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, - { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, - { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, - { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, - { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, - { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, - { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, - { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, - { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, - { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, - { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, - { url = "https://files.pythonhosted.org/packages/0a/a3/11d7f34ebbf3231bc907a3e6d5ee051b14d034c1bc7b65a97d5cc00516df/wrapt-2.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6f56a647e4eaf5f0ca40330fb070f566bdf9f7b0db89a1af20d71c28dcd7a0ab", size = 80879, upload-time = "2026-05-22T14:48:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/13/3c/b74cfd984cef560b900fb1a727af20352d89e1f06bf2e1114dd3f00f5f5a/wrapt-2.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:64b7deeda4b70408e382328d8bbe52a256fe9bc63ae3db86d804608367e5422c", size = 81462, upload-time = "2026-05-22T14:48:53.18Z" }, - { url = "https://files.pythonhosted.org/packages/15/a3/7c8f704b8dc07dfe0a5d01c2edbfd88317aa8e5e3fa7c743eb7a085ae767/wrapt-2.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9cf53ba90717db2e292401de290776c498d4bbfb0d4a559ca2895db8b9dcb5c", size = 167251, upload-time = "2026-05-22T14:48:54.562Z" }, - { url = "https://files.pythonhosted.org/packages/80/85/a34d1888d97247da6c2ff6118c3a721c73ed8cc4dd198c00208bb73b6f80/wrapt-2.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf3638274ab9d9b724c9baa0b4c04e132cd6faefb78b4dd3dd1a02a4bdaad41e", size = 166316, upload-time = "2026-05-22T14:48:56.065Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d7/72ffaeb01eebc704afe3fb99e840480f4bda45f0fa66e3381b6a39251c8f/wrapt-2.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aed9658797d0b45d6c49adcfc6b41f66e6f2d0c6de3ec79e16cf4b1855df240f", size = 157952, upload-time = "2026-05-22T14:48:57.924Z" }, - { url = "https://files.pythonhosted.org/packages/24/5b/36f5d6b024e4edfdd90b140742d11ebcf7836daf5c9daf326c55c24db412/wrapt-2.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d676ee388bc42a04d56dd7deb5605244dac2e35cc2fadbb43c9fa25bbd93508", size = 166130, upload-time = "2026-05-22T14:48:59.384Z" }, - { url = "https://files.pythonhosted.org/packages/81/06/9296d9e97bfdef5483dfcc859d57b095b257144b2bc5300ab521e06f4bc7/wrapt-2.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e395f7bc31851ef9b612050368cb446e9bc14cd7454b025018980349caf25ae5", size = 156604, upload-time = "2026-05-22T14:49:00.921Z" }, - { url = "https://files.pythonhosted.org/packages/53/37/16953929ed6776175720e58fc966e779926d8d71e2c7b2273230590ca71f/wrapt-2.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f1845c2a8cc1180ccccfa45785dd06f562730d19ef75be180334254012b6283", size = 166007, upload-time = "2026-05-22T14:49:02.332Z" }, - { url = "https://files.pythonhosted.org/packages/b9/73/20ee58c0612dae7c31131a7095345812ed2c7b389019e175f68cde34e5b4/wrapt-2.2.1-cp314-cp314-win32.whl", hash = "sha256:436addbc4bb4fc0a88c702577f51195d7d73683a7f3e0e5b253d8404d7847243", size = 78327, upload-time = "2026-05-22T14:49:03.722Z" }, - { url = "https://files.pythonhosted.org/packages/22/b3/ef7c3295d02e0448a71c639a36a057f46d524d057c9486291a7a3039e65c/wrapt-2.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:50972a1d974ea07725a7f6b1cec5f8759008afd030a0024843ebe7d52de47f2b", size = 81144, upload-time = "2026-05-22T14:49:05.093Z" }, - { url = "https://files.pythonhosted.org/packages/ac/dc/7bdf336953f99f4ceb0a584bb8870e42c8f26f93ea10c87834dad62f1668/wrapt-2.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:1c9934ea5d92957e3cd0adbc0845539dccfd62710ebe16195a8c66c53954db36", size = 79569, upload-time = "2026-05-22T14:49:06.413Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6d/6dfae80150ff1919c356d1dd528f049bcdfaae29b4d284bc957e022caef4/wrapt-2.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17de18fc12cea55b8a9587314cb830573e37fb33b247a7515696350863714188", size = 82892, upload-time = "2026-05-22T14:49:07.925Z" }, - { url = "https://files.pythonhosted.org/packages/82/7b/4e34766a7d7804ffce9e71befe47e9b3225dc350c49c94493c4ab39fd3a5/wrapt-2.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9dec1aca52dddde7df94818310fa2fe79739c8f385b2014c4cb1035f5508199", size = 83333, upload-time = "2026-05-22T14:49:09.257Z" }, - { url = "https://files.pythonhosted.org/packages/9d/57/0b34db3e8de44ccfece62d7b337abd1631dd810f5adc5f3db571727836b5/wrapt-2.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:69f2e9244542cb34dd59c7f073445b9e54ad9f3fce8d93606c368a1b499fc413", size = 202899, upload-time = "2026-05-22T14:49:10.572Z" }, - { url = "https://files.pythonhosted.org/packages/e5/45/ac0c459f154b99d92789a6cba7ca727185b83513b986f8ec7fe2aacddcbf/wrapt-2.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d83966dc7f4f45e8b97b5933685ac2e6e67fc0e19246ea314bceb9a8970c956", size = 209986, upload-time = "2026-05-22T14:49:12.229Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e4/77e37ff33ad018fa81ade52c25fa327b80b56f81d734279a63614fcb4cbc/wrapt-2.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78b0aa6bfb7be8deed0ab23e7aa028cc5210c29bc2d32a04d52b50e517a7307e", size = 194893, upload-time = "2026-05-22T14:49:14.139Z" }, - { url = "https://files.pythonhosted.org/packages/dd/9d/7ea651d1ab032fc5fa222fbec91d0f8a1397f6ae04ebb93fa7219aa921d7/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:05d5cb74d1b232ec8cfa130a8f900708699ff2491d97b8f85a4cdc5996294b85", size = 205636, upload-time = "2026-05-22T14:49:15.714Z" }, - { url = "https://files.pythonhosted.org/packages/09/af/8e88031a701275b9085c54e64bc88c0b1cd55c77eadd400691c371cd76c4/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f6518b94edb9150452e9aba08027d4cc293433753ec1fbefb4629a21cbc74181", size = 192267, upload-time = "2026-05-22T14:49:17.283Z" }, - { url = "https://files.pythonhosted.org/packages/bf/a8/e657ca876b06710194f243d81c4b0896ade646e244bdbec2d87c8c56a8bd/wrapt-2.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed55af48b3eb28f43228ca2306788892bcb629eb2b5c4876e2a3659872c2f17a", size = 198378, upload-time = "2026-05-22T14:49:18.785Z" }, - { url = "https://files.pythonhosted.org/packages/c8/59/822efe4ea722a3961331bfa35b7d90937790d2c20f0616de1997ccc3aebd/wrapt-2.2.1-cp314-cp314t-win32.whl", hash = "sha256:2e08688ab16525897da6589d56d0aebaf417bbe91c2d8e3b96203b1efa596e85", size = 80226, upload-time = "2026-05-22T14:49:20.264Z" }, - { url = "https://files.pythonhosted.org/packages/ab/31/2a7dc5f6abb2fca0b6e1610e120419f603650aceb4f1d3ac4cae0354e162/wrapt-2.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:fd0135d34387f5fd087d9be368ea77ea89cf2451dc1cd1c622d35021bcb3ab50", size = 83835, upload-time = "2026-05-22T14:49:21.634Z" }, - { url = "https://files.pythonhosted.org/packages/9f/c0/782b86e28d1ceebeb74cccea12d2cd3d2ba0bd68e3dec20b1bc5873f6127/wrapt-2.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f70db64e8266d7c45d3b735f2e08eeb434b5e03da9a479ae42b2e2e486a21a00", size = 80722, upload-time = "2026-05-22T14:49:23.59Z" }, - { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, -] - [[package]] name = "yarl" version = "1.24.2" From 294aa6738056decc09e7788418018d138de388ce Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Mon, 15 Jun 2026 07:39:07 -0500 Subject: [PATCH 13/16] fix(slackbot): removed missing reference to alembic --- apps/slackbot/utilities/routing.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/slackbot/utilities/routing.py b/apps/slackbot/utilities/routing.py index 33c7ca39..6c545917 100644 --- a/apps/slackbot/utilities/routing.py +++ b/apps/slackbot/utilities/routing.py @@ -151,8 +151,6 @@ actions.BACKBLAST_NEW_BLANK_BUTTON: (backblast.build_backblast_form, True), actions.REGION_INFO_BUTTON: (region.build_region_form, False), actions.CONFIG_SPECIAL_EVENTS: (special_events.build_special_settings_form, False), - actions.DB_ADMIN_UPGRADE: (db_admin.handle_db_admin_upgrade, False), - actions.DB_ADMIN_RESET: (db_admin.handle_db_admin_reset, False), actions.SECRET_MENU_CALENDAR_IMAGES: (db_admin.handle_calendar_image_refresh, False), actions.CONFIG_SLT: (positions.build_config_slt_form, True), actions.SLT_LEVEL_SELECT: (positions.build_config_slt_form, False), From 16b69931e40860446785e747037cb9d88e9004e4 Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Mon, 15 Jun 2026 07:52:13 -0500 Subject: [PATCH 14/16] feat(slackbot): added local event instance seeding --- packages/db/src/local-seed-lib/events.ts | 138 +++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/packages/db/src/local-seed-lib/events.ts b/packages/db/src/local-seed-lib/events.ts index a1a41d8d..3eedf2d3 100644 --- a/packages/db/src/local-seed-lib/events.ts +++ b/packages/db/src/local-seed-lib/events.ts @@ -5,6 +5,127 @@ import { schema } from ".."; import type { AppDb } from "../client"; import { AOS, EVENT_TYPES } from "./data"; +const DAYS_OF_WEEK = [ + "sunday", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", +] as const; + +function parseDate(dateStr: string): Date { + const [year, month, day] = dateStr.split("-").map(Number); + return new Date(year!, month! - 1, day); +} + +function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function getDayOfWeek(date: Date): (typeof DAYS_OF_WEEK)[number] { + return DAYS_OF_WEEK[date.getDay()]!; +} + +function addDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +} + +async function seedEventInstancesForCurrentYear( + db: AppDb, + event: typeof schema.events.$inferSelect, +): Promise { + if (!event.dayOfWeek) { + return 0; + } + + const currentYear = new Date().getFullYear(); + const yearStart = new Date(currentYear, 0, 1); + const yearEnd = new Date(currentYear, 11, 31); + const eventStartDate = parseDate(event.startDate); + const eventEndDate = event.endDate ? parseDate(event.endDate) : yearEnd; + + const startDate = eventStartDate > yearStart ? eventStartDate : yearStart; + const endDate = eventEndDate < yearEnd ? eventEndDate : yearEnd; + + if (startDate > endDate) { + return 0; + } + + const existingInstances = await db + .select({ startDate: schema.eventInstances.startDate }) + .from(schema.eventInstances) + .where(eq(schema.eventInstances.seriesId, event.id)); + const existingInstanceDates = new Set( + existingInstances.map((instance) => instance.startDate), + ); + + const instanceRecords: (typeof schema.eventInstances.$inferInsert)[] = []; + let currentDate = new Date(startDate); + + while (currentDate <= endDate) { + if (getDayOfWeek(currentDate) === event.dayOfWeek) { + const instanceDate = formatDate(currentDate); + + if (!existingInstanceDates.has(instanceDate)) { + instanceRecords.push({ + orgId: event.orgId, + locationId: event.locationId, + seriesId: event.id, + isActive: event.isActive, + highlight: event.highlight, + startDate: instanceDate, + endDate: instanceDate, + startTime: event.startTime, + endTime: event.endTime, + name: event.name, + description: event.description, + isPrivate: event.isPrivate, + meta: event.meta, + }); + } + } + + currentDate = addDays(currentDate, 1); + } + + if (instanceRecords.length === 0) { + return 0; + } + + const createdInstances = await db + .insert(schema.eventInstances) + .values(instanceRecords) + .returning({ id: schema.eventInstances.id }); + + const eventTypeAssociations = await db + .select({ eventTypeId: schema.eventsXEventTypes.eventTypeId }) + .from(schema.eventsXEventTypes) + .where(eq(schema.eventsXEventTypes.eventId, event.id)); + + if (eventTypeAssociations.length > 0 && createdInstances.length > 0) { + await db + .insert(schema.eventInstancesXEventTypes) + .values( + createdInstances.flatMap((instance) => + eventTypeAssociations.map(({ eventTypeId }) => ({ + eventInstanceId: instance.id, + eventTypeId, + })), + ), + ) + .onConflictDoNothing(); + } + + return createdInstances.length; +} + export async function seedEventTypes( db: AppDb, ): Promise<(typeof schema.eventTypes.$inferSelect)[]> { @@ -107,6 +228,23 @@ export async function seedAoLocationsAndEvents( console.log(` + Inserted event for AO: ${ao.name}`); } } + + const [event] = await db + .select() + .from(schema.events) + .where(eq(schema.events.orgId, aoId)); + + if (event) { + const insertedInstanceCount = await seedEventInstancesForCurrentYear( + db, + event, + ); + if (insertedInstanceCount > 0) { + console.log( + ` + Inserted ${insertedInstanceCount} event instance(s) for AO: ${ao.name}`, + ); + } + } } } } From 390ced96def025328dff72cb2ef030ccef82aaca Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Mon, 15 Jun 2026 08:07:31 -0500 Subject: [PATCH 15/16] feat(slackbot): adding enhancements to the connect region flow --- apps/slackbot/README.md | 6 +++++- apps/slackbot/features/connect.py | 25 ++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/apps/slackbot/README.md b/apps/slackbot/README.md index fa9975c2..6f9ad70f 100644 --- a/apps/slackbot/README.md +++ b/apps/slackbot/README.md @@ -34,10 +34,14 @@ From the repo root: pnpm dev ``` -Slackbot starts automatically with the rest of the monorepo apps. +Slackbot starts automatically with the rest of the monorepo apps. At the very least, you will need to also run the API app. - Slackbot local URL: http://localhost:3006 +### First steps + +Once the app is running, you will want to connect your local app to one of the seeded regions. To do this, use the `/f3-nation-settings` command, click on `Migration Settings`, then search for a seeded region (F3 Charlotte is one). You will be automatically "approved" when in local development. + ## Step debugging 1. Set `ENABLE_DEBUGGING=true` in `apps/slackbot/.env`. diff --git a/apps/slackbot/features/connect.py b/apps/slackbot/features/connect.py index 8415717f..3bc86833 100644 --- a/apps/slackbot/features/connect.py +++ b/apps/slackbot/features/connect.py @@ -183,6 +183,16 @@ def handle_existing_region_selection( if LOCAL_DEVELOPMENT: body["message"] = {"metadata": metadata} handle_approve_connection(body, client, logger, context, region_record) + view: View = View( + type="modal", + title="Connect existing region", + blocks=[ + SectionBlock( + text="Since this is a local development environment, the connection request has been automatically approved. You can close this window!", # noqa + ) + ], + ) + client.views_update(view_id=safe_get(body, "view", "previous_view_id"), view=view) return blocks = [ @@ -268,6 +278,19 @@ def handle_existing_region_selection( ) except Exception as e: logger.error(f"Error sending region connection request: {e}") + + # update modal to indicate that the request has been submitted and is pending review + + form: View = View( + type="modal", + title="Connect existing region", + blocks=[ + SectionBlock( + text="Your connection request has been submitted and is pending review by the F3 Nation Admins. I will send you a direct message when the review is complete.", # noqa + ), + ], + ) + client.views_update(view_id=safe_get(body, "view", "previous_view_id"), view=form) def handle_new_region_creation( @@ -336,7 +359,7 @@ def handle_approve_connection( blocks = [ HeaderBlock(text="Region Connection Request Approved"), SectionBlock( - text=f"Your region connection request was approved by the F3 Nation Admins! Your slack space is now connected to {metadata.get('region_name')}. Events have been created starting on {metadata.get('migration_date')}, and your PAX can start signing up to Q through the `/f3-calendar` command." # noqa + text=f"Your region connection request was approved by the F3 Nation Admins! Your slack space is now connected to {metadata.get('region_name')}. Your PAX can start signing up to Q through the `/f3-calendar` command." # noqa ), ] ssl_context = ssl.create_default_context() From 3e33938edc3e7e81fb726f4cd01e84b139b9f21f Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Mon, 15 Jun 2026 15:53:27 -0500 Subject: [PATCH 16/16] feat(slackbot): handling phone addition to orgs --- apps/slackbot/application/ao/__init__.py | 1 + apps/slackbot/infrastructure/api_client/ao_repository.py | 3 +++ .../tests/infrastructure/api_client/test_ao_repository.py | 2 ++ packages/db-python/f3_data_models/models.py | 2 ++ packages/db/src/local-seed-lib/users.ts | 6 +++++- 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/slackbot/application/ao/__init__.py b/apps/slackbot/application/ao/__init__.py index 1d2ef617..b10e9fdf 100644 --- a/apps/slackbot/application/ao/__init__.py +++ b/apps/slackbot/application/ao/__init__.py @@ -11,3 +11,4 @@ class AoData(BaseModel): default_location_id: int | None = None logo_url: str | None = None meta: dict | None = None + phone: str | None = None diff --git a/apps/slackbot/infrastructure/api_client/ao_repository.py b/apps/slackbot/infrastructure/api_client/ao_repository.py index 44a44510..81b5fb83 100644 --- a/apps/slackbot/infrastructure/api_client/ao_repository.py +++ b/apps/slackbot/infrastructure/api_client/ao_repository.py @@ -25,6 +25,7 @@ def _parse_ao(raw: dict) -> AoData: default_location_id=raw.get("defaultLocationId", raw.get("default_location_id")), logo_url=raw.get("logoUrl", raw.get("logo_url")), meta=raw.get("meta"), + phone=raw.get("phone"), ) @@ -71,6 +72,7 @@ def create( "facebook": "", "instagram": "", "meta": {"slack_channel_id": slack_channel_id} if slack_channel_id else {}, + "phone": "", } if description is not None: payload["description"] = description @@ -102,6 +104,7 @@ def update( "facebook": "", "instagram": "", "meta": {"slack_channel_id": slack_channel_id} if slack_channel_id else {}, + "phone": "", } if description is not None: payload["description"] = description diff --git a/apps/slackbot/tests/infrastructure/api_client/test_ao_repository.py b/apps/slackbot/tests/infrastructure/api_client/test_ao_repository.py index c39acdc2..c71b8ec9 100644 --- a/apps/slackbot/tests/infrastructure/api_client/test_ao_repository.py +++ b/apps/slackbot/tests/infrastructure/api_client/test_ao_repository.py @@ -131,6 +131,7 @@ def test_create_posts_correct_payload(self): "twitter": "", "facebook": "", "instagram": "", + "phone": "", }, ) self.assertEqual(result.id, 20) @@ -179,6 +180,7 @@ def test_update_posts_correct_payload(self): "twitter": "", "facebook": "", "instagram": "", + "phone": "", }, ) diff --git a/packages/db-python/f3_data_models/models.py b/packages/db-python/f3_data_models/models.py index bff35c3d..16a49e85 100644 --- a/packages/db-python/f3_data_models/models.py +++ b/packages/db-python/f3_data_models/models.py @@ -453,6 +453,7 @@ class Org(Base): ao_count (int): The number of AOs associated with the organization. Defaults to 0, will be updated by triggers. created (datetime): The timestamp when the record was created. updated (datetime): The timestamp when the record was last updated. + phone (Optional[str]): The organization's phone number. locations (Optional[List[Location]]): The locations associated with the organization. Probably only relevant for regions. event_types (Optional[List[EventType]]): The event types associated with the organization. Used to control which event types are available for selection at the region level. @@ -482,6 +483,7 @@ class Org(Base): ao_count: Mapped[Optional[int]] = mapped_column(Integer, default=0, nullable=True) created: Mapped[dt_create] updated: Mapped[dt_update] + phone: Mapped[Optional[str]] __table_args__ = ( Index("idx_orgs_parent_id", "parent_id"), diff --git a/packages/db/src/local-seed-lib/users.ts b/packages/db/src/local-seed-lib/users.ts index 14bb0aab..c1749fdf 100644 --- a/packages/db/src/local-seed-lib/users.ts +++ b/packages/db/src/local-seed-lib/users.ts @@ -105,7 +105,11 @@ export async function seedApiKeys( if (keyId) { const roleId = - apiKey.role === "editor" ? roleIds.editorId : roleIds.userId; + apiKey.role === "admin" + ? roleIds.adminId + : apiKey.role === "editor" + ? roleIds.editorId + : roleIds.userId; await db .insert(schema.rolesXApiKeysXOrg) .values({ apiKeyId: keyId, roleId, orgId: nationId })