diff --git a/.env.example b/.env.example index 3fe20be..31b571d 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,11 @@ LOG_LEVEL=INFO # Optional: generate with `uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` # Keep this stable and backed up separately; changing it makes saved API keys unreadable. AI_ENCRYPTION_KEY= + +# Optional: enable the admin Compose profile with Telegram OIDC and these settings. +ADMIN_DATABASE_PATH=data/admin/access.db +ADMIN_TELEGRAM_IDS= +ADMIN_TELEGRAM_CLIENT_ID= +ADMIN_TELEGRAM_CLIENT_SECRET= +ADMIN_TELEGRAM_REDIRECT_URI= +ADMIN_SESSION_SECRET= diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c526b5..e5ae9ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,9 @@ Remindly 的重要變更會記錄在這裡。 ## Unreleased +- 後台人員權限:部署時以 `ADMIN_TELEGRAM_IDS` 指定管理者,登入後可新增/移除維護者;獨立 SQLite 保存白名單,移除後下次請求失效。人員列表顯示登入取得的名稱、管理者備註,以及名稱旁的帳號與 ID 藥丸。 - 群組共用 AI:管理員開放綁定、群組按鈕進私聊選既有 Key 授權、管理員確認啟用;僅引用指定 Key,支援撤銷、刪 Key 失效及群組內建解析降級(schema v10)。 +- 解析問題回報:使用者可從提醒確認卡進私聊回報,先確認回報者資訊與選填附件,再送出。維護者可用 Telegram 登入後台,即時篩選並查看回報詳情(schema v12)。 ## v0.5.0 - 2026-11-05 diff --git a/README.md b/README.md index 6025d69..7ba6bcc 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ - 💬 **缺漏資訊追問**:缺少資訊時主動追問;內建解析在日期明確但缺少時間時,提供 09:00、12:00、15:00、18:00 快捷按鈕。 +- 🐛 **解析問題回報**:從確認卡進入 Bot 私聊回報,可選擇附上原句與解析結果;維護者可在後台查閱。 + - 🛠️ **互動式提醒管理**:`/list` 依建立者分組,並支援今天、本週、我的、全部篩選。週期性提醒會以 `[重複]` 前綴標示。 - 👥 **群組與多人提醒**:支援私聊與群組,到期時可 mention 相關使用者。 @@ -196,6 +198,13 @@ Telegram 的指令選單點選 `/groupmode` 時可能會直接送出,這是正 若 BotFather 的 Group Privacy 沒關,bot 仍收不到一般群組訊息。請到 `@BotFather` 執行 `/setprivacy`,選擇 bot 後設為 `Disable`;若原本已在群組,必要時把 bot 移除後重新加入。 +### 回報解析問題 + +在確認卡的「使用 … 解析」旁點「回報問題」,在 Bot 私聊選原因、預覽,再按「送出回報」。 +只有草稿建立者可以回報,附件預設不附。附上時會先顯示實際送出的原句及解析結果,過長的欄位會截短。 +送出前會提示將記錄 Telegram user ID 與帳號(如有設定 `@username`),供維護者在回報詳情查閱。 +回報後仍可回到原聊天確認或取消提醒。請在草稿取消、確認或過期前送出。 + ## 部署 ### 部署檢查清單 @@ -256,6 +265,32 @@ docker compose up -d --build SQLite 會存在 named volume `remindly-data`,container 內 DB 路徑固定為 `/app/data/reminders.db`。 +### 回報維護後台(選用) + +1. 在 BotFather 的 Telegram 登入設定登記後台 HTTPS 網址與回呼 `https://你的後台網域/auth/telegram`, + 保留預設 RS256 簽章。將回呼填入 `ADMIN_TELEGRAM_REDIRECT_URI`,並填入 + `ADMIN_TELEGRAM_CLIENT_ID` 與 `ADMIN_TELEGRAM_CLIENT_SECRET`。登入密鑰由 BotFather 提供。 +2. 將後台管理者的數字 Telegram user ID 以逗號分隔填入 `ADMIN_TELEGRAM_IDS`。 + 用 `uv run python -c "import secrets; print(secrets.token_urlsafe(32))"` 產生 `ADMIN_SESSION_SECRET`。 +3. 用 HTTPS 反向代理將後台網域轉到部署主機的 `127.0.0.1:8081`,再從專案目錄啟動: + + ```sh + docker compose --profile admin up -d --build + ``` + +維護者用 Telegram 帳號登入後,可依原因、解析來源、模型和 Bot 版本篩選回報,點原因開啟詳情。 +後台管理者可在「人員權限」新增或移除維護者、編輯備註名稱;維護者只能查閱回報,不能管理人員。 +人員列表顯示本人最近登入取得的 Telegram 名稱與帳號,以及數字 ID;尚未登入時以管理者備註辨識。 +移除維護者後,下次請求即失去權限,已登入的 session 也一樣。 +管理者只能由部署設定授予或撤銷,更新 `ADMIN_TELEGRAM_IDS` 後執行 `docker compose --profile admin up -d --no-deps --force-recreate remindly-admin`。 +移除的管理者在新容器接手後失去管理權限;若也列為維護者,仍可查閱回報。 +舊的 `MAINTAINER_TELEGRAM_IDS` 不再使用;既有維護者請由管理者在後台重新加入。 +首次部署時若未設定管理者,將沒有人可以授予維護者權限。 + +人員白名單保存於獨立 SQLite,`ADMIN_DATABASE_PATH` 預設為 `data/admin/access.db`, +Compose 使用 `remindly-admin-data` volume 的 `/app/admin/access.db`,須與提醒資料庫分開備份。 +回報資料庫仍唯讀掛載;後台無法透過人員管理修改提醒或 AI Key。 + ### GHCR 發布映像 正式版本映像使用 `ghcr.io/heitang/remindly`,提供 `linux/amd64` 與 `linux/arm64`。 @@ -277,6 +312,7 @@ services: 這是替換既有 service 的 `image` 設定;同時移除 `build` 與 `pull_policy: build`, 保留原本環境變數與 `remindly-data` volume。開發用 Compose 仍可從原始碼建置。 +若啟用後台,`remindly-admin` 也使用相同的映像 digest,並移除其 `pull_policy: never`。 映像發布不觸發部署;升級前須另行備份資料庫及 AI 主金鑰,回滾也須確認 DB 相容性。 ### SQLite 備份 @@ -347,6 +383,7 @@ Smoke test 會模擬: | `src/remindly/storage/` | SQLite repository、session stores、versioned migrations。 | | `src/remindly/telegram/` | Telegram Bot API client 與 DTO。 | | `src/remindly/app.py` | Composition root,組裝 dependencies 並啟動 polling/scheduler。 | +| `src/remindly/admin.py` | 維護後台、Telegram 登入與回報查詢。 | ### GitOps 流程 @@ -356,7 +393,7 @@ main -> dev -> feature/ -> PR -> dev -> PR -> main Feature branch 合併到 `dev` 必須開 PR;`dev` 驗證通過後再以 release PR 合併回 `main`。 -Runtime 新增 `cryptography` 供 Key 加密。更新 runtime dependencies 後同步 Docker 安裝清單: +更新 runtime dependencies 後同步 Docker 安裝清單: ```sh uv export --locked --no-dev --no-emit-project --format requirements-txt --output-file requirements.txt diff --git a/docker-compose.yml b/docker-compose.yml index 24645b1..9f6f8d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,31 @@ services: restart: unless-stopped stop_grace_period: 15s + remindly-admin: + profiles: [admin] + image: remindly:local + pull_policy: never + init: true + depends_on: + - remindly + command: ["gunicorn", "--bind", "0.0.0.0:8081", "remindly.admin:create_app()"] + environment: + DATABASE_PATH: /app/data/reminders.db + ADMIN_DATABASE_PATH: /app/admin/access.db + ADMIN_TELEGRAM_IDS: ${ADMIN_TELEGRAM_IDS:-} + ADMIN_TELEGRAM_CLIENT_ID: ${ADMIN_TELEGRAM_CLIENT_ID:-} + ADMIN_TELEGRAM_CLIENT_SECRET: ${ADMIN_TELEGRAM_CLIENT_SECRET:-} + ADMIN_TELEGRAM_REDIRECT_URI: ${ADMIN_TELEGRAM_REDIRECT_URI:-} + ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-} + volumes: + - remindly-data:/app/data:ro + - remindly-admin-data:/app/admin + ports: + - "127.0.0.1:8081:8081" + restart: unless-stopped + volumes: + remindly-admin-data: + name: remindly-admin-data remindly-data: name: remindly-data diff --git a/pyproject.toml b/pyproject.toml index 425278a..2ef77b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,12 @@ description = "Remindly" readme = "README.md" requires-python = ">=3.12" dependencies = [ + "authlib>=1.7,<2", "cryptography>=46,<47", + "flask>=3.1,<4", + "gunicorn>=23,<27", + "joserfc>=1.6,<2", + "requests>=2.32.4,<3", ] [build-system] diff --git a/requirements.txt b/requirements.txt index 7b8d2cd..c2dc2bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,17 @@ # This file was autogenerated by uv via the following command: -# uv export --locked --no-dev --no-emit-project --format requirements-txt --output-file requirements.txt +# uv export --locked --offline --no-dev --no-emit-project --format requirements-txt --output-file requirements.txt +authlib==1.9.5 \ + --hash=sha256:3f031d5e9b6486089e91e8f7f1b68f36275f3151c66460d650fc90d420545fd7 \ + --hash=sha256:e32a63e3d869196b61816b2602231862179a2ffc29358c009d1f022c8b86a8b2 + # via remindly +blinker==1.9.0 \ + --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ + --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc + # via flask +certifi==2026.10.23 \ + --hash=sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd \ + --hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1 + # via requests cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ @@ -77,6 +89,136 @@ cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 # via cryptography +charset-normalizer==3.5.2 \ + --hash=sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e \ + --hash=sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf \ + --hash=sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5 \ + --hash=sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56 \ + --hash=sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26 \ + --hash=sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718 \ + --hash=sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93 \ + --hash=sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3 \ + --hash=sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875 \ + --hash=sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e \ + --hash=sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204 \ + --hash=sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234 \ + --hash=sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3 \ + --hash=sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98 \ + --hash=sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187 \ + --hash=sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d \ + --hash=sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f \ + --hash=sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7 \ + --hash=sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011 \ + --hash=sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869 \ + --hash=sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847 \ + --hash=sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9 \ + --hash=sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93 \ + --hash=sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd \ + --hash=sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00 \ + --hash=sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0 \ + --hash=sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09 \ + --hash=sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621 \ + --hash=sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8 \ + --hash=sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a \ + --hash=sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51 \ + --hash=sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0 \ + --hash=sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef \ + --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 \ + --hash=sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229 \ + --hash=sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e \ + --hash=sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd \ + --hash=sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115 \ + --hash=sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253 \ + --hash=sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995 \ + --hash=sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438 \ + --hash=sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0 \ + --hash=sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b \ + --hash=sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7 \ + --hash=sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a \ + --hash=sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a \ + --hash=sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c \ + --hash=sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5 \ + --hash=sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e \ + --hash=sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4 \ + --hash=sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800 \ + --hash=sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055 \ + --hash=sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c \ + --hash=sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b \ + --hash=sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80 \ + --hash=sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a \ + --hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 \ + --hash=sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2 \ + --hash=sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58 \ + --hash=sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc \ + --hash=sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639 \ + --hash=sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf \ + --hash=sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d \ + --hash=sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f \ + --hash=sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c \ + --hash=sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc \ + --hash=sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4 \ + --hash=sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253 \ + --hash=sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade \ + --hash=sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858 \ + --hash=sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26 \ + --hash=sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8 \ + --hash=sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13 \ + --hash=sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1 \ + --hash=sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03 \ + --hash=sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364 \ + --hash=sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4 \ + --hash=sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0 \ + --hash=sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036 \ + --hash=sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3 \ + --hash=sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21 \ + --hash=sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3 \ + --hash=sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e \ + --hash=sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413 \ + --hash=sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21 \ + --hash=sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346 \ + --hash=sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429 \ + --hash=sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685 \ + --hash=sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f \ + --hash=sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c \ + --hash=sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d \ + --hash=sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad \ + --hash=sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb \ + --hash=sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc \ + --hash=sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c \ + --hash=sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf \ + --hash=sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604 \ + --hash=sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f \ + --hash=sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105 \ + --hash=sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a \ + --hash=sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d \ + --hash=sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1 \ + --hash=sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e \ + --hash=sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709 \ + --hash=sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874 \ + --hash=sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc \ + --hash=sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95 \ + --hash=sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd \ + --hash=sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0 \ + --hash=sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d \ + --hash=sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3 \ + --hash=sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c \ + --hash=sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3 \ + --hash=sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491 \ + --hash=sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5 \ + --hash=sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5 \ + --hash=sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655 \ + --hash=sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd \ + --hash=sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084 \ + --hash=sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d \ + --hash=sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915 \ + --hash=sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1 \ + --hash=sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424 \ + --hash=sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f + # via requests +click==8.5.1 \ + --hash=sha256:8bb5376d2006950386f8c6427613334a2acadc5f559cb80e638d00b4df957e37 \ + --hash=sha256:9bbfada76703fd1d784b83eea012e3e8c6a001a876543ef104fa56aaaeb072b4 + # via flask cryptography==46.0.7 \ --hash=sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832 \ --hash=sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067 \ @@ -121,8 +263,160 @@ cryptography==46.0.7 \ --hash=sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e \ --hash=sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298 \ --hash=sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce + # via + # authlib + # joserfc + # remindly +flask==3.1.3 \ + --hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \ + --hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c + # via remindly +gunicorn==26.2.0 \ + --hash=sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447 \ + --hash=sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3 # via remindly +idna==3.20 \ + --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c + # via requests +itsdangerous==2.2.0 \ + --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ + --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 + # via flask +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via flask +joserfc==1.8.0 \ + --hash=sha256:176b836ac7ed9a46a88629e24076314b475f33a75d0bad7f26146e572eed7c79 \ + --hash=sha256:cbf03cb2c6db8cc5289f1ef8718842e53dba244b4c0de5fefa0c0eaee4bde8db + # via + # authlib + # remindly +markupsafe==3.0.4 \ + --hash=sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002 \ + --hash=sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b \ + --hash=sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653 \ + --hash=sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c \ + --hash=sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e \ + --hash=sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc \ + --hash=sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92 \ + --hash=sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f \ + --hash=sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4 \ + --hash=sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7 \ + --hash=sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691 \ + --hash=sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc \ + --hash=sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde \ + --hash=sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99 \ + --hash=sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9 \ + --hash=sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df \ + --hash=sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5 \ + --hash=sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17 \ + --hash=sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8 \ + --hash=sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea \ + --hash=sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248 \ + --hash=sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741 \ + --hash=sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6 \ + --hash=sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7 \ + --hash=sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1 \ + --hash=sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67 \ + --hash=sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f \ + --hash=sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9 \ + --hash=sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c \ + --hash=sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc \ + --hash=sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba \ + --hash=sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17 \ + --hash=sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6 \ + --hash=sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2 \ + --hash=sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163 \ + --hash=sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b \ + --hash=sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634 \ + --hash=sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38 \ + --hash=sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed \ + --hash=sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c \ + --hash=sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148 \ + --hash=sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a \ + --hash=sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f \ + --hash=sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811 \ + --hash=sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2 \ + --hash=sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6 \ + --hash=sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed \ + --hash=sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0 \ + --hash=sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b \ + --hash=sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96 \ + --hash=sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59 \ + --hash=sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2 \ + --hash=sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65 \ + --hash=sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72 \ + --hash=sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8 \ + --hash=sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e \ + --hash=sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a \ + --hash=sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2 \ + --hash=sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453 \ + --hash=sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6 \ + --hash=sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977 \ + --hash=sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978 \ + --hash=sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581 \ + --hash=sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369 \ + --hash=sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a \ + --hash=sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916 \ + --hash=sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b \ + --hash=sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f \ + --hash=sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c \ + --hash=sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464 \ + --hash=sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9 \ + --hash=sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee \ + --hash=sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300 \ + --hash=sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6 \ + --hash=sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d \ + --hash=sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46 \ + --hash=sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97 \ + --hash=sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733 \ + --hash=sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe \ + --hash=sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429 \ + --hash=sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39 \ + --hash=sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894 \ + --hash=sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c \ + --hash=sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c \ + --hash=sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77 \ + --hash=sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe \ + --hash=sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad \ + --hash=sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85 \ + --hash=sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e \ + --hash=sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34 \ + --hash=sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c \ + --hash=sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749 \ + --hash=sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214 \ + --hash=sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932 \ + --hash=sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494 \ + --hash=sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1 \ + --hash=sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0 \ + --hash=sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786 \ + --hash=sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78 \ + --hash=sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e \ + --hash=sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289 \ + --hash=sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c \ + --hash=sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe \ + --hash=sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237 \ + --hash=sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19 \ + --hash=sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977 + # via + # flask + # jinja2 + # werkzeug pycparser==3.11 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ --hash=sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80 \ --hash=sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc # via cffi +requests==2.34.2 \ + --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ + --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed + # via remindly +urllib3==2.8.0 \ + --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \ + --hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63 + # via requests +werkzeug==3.1.9 \ + --hash=sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060 \ + --hash=sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab + # via flask diff --git a/src/remindly/admin.py b/src/remindly/admin.py new file mode 100644 index 0000000..7e4e484 --- /dev/null +++ b/src/remindly/admin.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +import json +import os +import secrets +import sqlite3 +from contextlib import closing +from datetime import timedelta +from pathlib import Path +from urllib.parse import urlsplit + +from authlib.integrations.flask_client import OAuth, OAuthError +from flask import Flask, abort, g, redirect, render_template, request, session, url_for +from joserfc.errors import JoseError +from requests import RequestException + +from remindly.reminders.reports import ATTACHMENT_FIELDS, PARSER_SOURCES, REPORT_REASONS +from remindly.settings import load_dotenv + +_ISSUER = "https://oauth.telegram.org" +_PAGE_SIZE = 50 +_FILTERS = ("reason", "parser_source", "model", "bot_version") + + +def error_page(message: str, status: int): + return render_template("reports.html", screen="error", message=message), status + + +def create_app() -> Flask: + load_dotenv() + client_id = os.getenv("ADMIN_TELEGRAM_CLIENT_ID", "").strip() + client_secret = os.getenv("ADMIN_TELEGRAM_CLIENT_SECRET", "").strip() + session_secret = os.getenv("ADMIN_SESSION_SECRET", "").strip() + redirect_uri = os.getenv("ADMIN_TELEGRAM_REDIRECT_URI", "").strip() + callback = urlsplit(redirect_uri) + if ( + not client_id + or not client_secret + or len(session_secret) < 32 + or callback.scheme != "https" + or not callback.hostname + or callback.username is not None + or callback.path != "/auth/telegram" + or callback.query + or callback.fragment + ): + raise RuntimeError("Configure the Telegram login callback and admin secrets first.") + + database = Path(os.getenv("DATABASE_PATH", "data/reminders.db")).resolve().as_uri() + access_database = Path(os.getenv("ADMIN_DATABASE_PATH", "data/admin/access.db")).resolve() + if access_database.as_uri() == database: + raise RuntimeError("Admin access database must be separate from the report database.") + access_database.parent.mkdir(parents=True, exist_ok=True) + with closing(sqlite3.connect(access_database)) as connection, connection: + connection.execute("begin immediate") + connection.execute( + "create table if not exists maintainers (" + "user_id integer primary key check(user_id > 0), " + "added_by integer not null, added_at text not null default CURRENT_TIMESTAMP)" + ) + columns = {row[1] for row in connection.execute("pragma table_info(maintainers)")} + if "note" not in columns: + connection.execute("alter table maintainers add column note text not null default ''") + connection.execute( + "create table if not exists user_profiles (" + "user_id integer primary key, display_name text, username text, " + "last_login_at text not null default CURRENT_TIMESTAMP)" + ) + app = Flask(__name__) + app.config.update( + SECRET_KEY=session_secret, + SESSION_COOKIE_NAME="__Host-remindly-admin", + SESSION_COOKIE_SECURE=True, + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_REFRESH_EACH_REQUEST=False, + PERMANENT_SESSION_LIFETIME=timedelta(hours=8), + ADMIN_TELEGRAM_IDS=frozenset( + int(value) + for raw in os.getenv("ADMIN_TELEGRAM_IDS", "").split(",") + if (value := raw.strip()) + ), + ) + oauth = OAuth(app) + telegram = oauth.register( + "telegram", + client_id=client_id, + client_secret=client_secret, + authorize_url=f"{_ISSUER}/auth", + access_token_url=f"{_ISSUER}/token", + issuer=_ISSUER, + jwks_uri=f"{_ISSUER}/.well-known/jwks.json", + id_token_signing_alg_values_supported=["RS256"], + client_kwargs={ + "scope": "openid profile", + "code_challenge_method": "S256", + "token_endpoint_auth_method": "client_secret_basic", + "default_timeout": 8, + }, + ) + + if any(not 0 < user_id < 2**63 for user_id in app.config["ADMIN_TELEGRAM_IDS"]): + raise RuntimeError("ADMIN_TELEGRAM_IDS must contain positive Telegram user IDs.") + + def is_admin(user_id: object) -> bool: + return type(user_id) is int and user_id in app.config["ADMIN_TELEGRAM_IDS"] + + def can_read(user_id: object) -> bool: + if type(user_id) is not int or not 0 < user_id < 2**63: + return False + if is_admin(user_id): + return True + with closing(sqlite3.connect(access_database)) as connection: + return ( + connection.execute( + "select 1 from maintainers where user_id = ?", (user_id,) + ).fetchone() + is not None + ) + + def check_csrf(): + expected = session.get("logout_token", "") + if not expected or not secrets.compare_digest( + request.form.get("token", "").encode(), expected.encode() + ): + abort(403) + + def read_database() -> sqlite3.Connection: + connection = sqlite3.connect(f"{database}?mode=ro", uri=True) + connection.row_factory = sqlite3.Row + return connection + + @app.before_request + def require_maintainer(): + if request.endpoint in {None, "login", "start_login", "auth_callback", "static"}: + return None + if "telegram_user_id" not in session: + return redirect(url_for("login")) + if not can_read(session["telegram_user_id"]): + session.clear() + abort(403) + g.is_admin = is_admin(session["telegram_user_id"]) + if ( + request.endpoint + in {"maintainers", "add_maintainer", "remove_maintainer", "update_maintainer_note"} + and not g.is_admin + ): + abort(403) + return None + + @app.after_request + def protect_response(response): + response.headers["Cache-Control"] = "no-store" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Content-Security-Policy"] = ( + "default-src 'none'; style-src 'self'; script-src 'self'; form-action 'self'; " + "frame-ancestors 'none'; base-uri 'none'" + ) + return response + + @app.errorhandler(sqlite3.OperationalError) + def database_unavailable(error): + app.logger.warning("Report database unavailable: %s", type(error).__name__) + return error_page("回報資料暫時無法讀取。", 503) + + @app.errorhandler(403) + def access_denied(_error): + return error_page("此帳號沒有回報查閱權限。", 403) + + @app.get("/login") + def login(): + return render_template("reports.html", screen="login") + + @app.get("/login/telegram") + def start_login(): + session.clear() + try: + return telegram.authorize_redirect(redirect_uri) + except RequestException: + return error_page("Telegram 登入暫時無法使用。", 502) + + @app.get("/auth/telegram") + def auth_callback(): + try: + token = telegram.authorize_access_token(leeway=0) + except (OAuthError, JoseError, RequestException, ValueError): + session.clear() + return error_page("Telegram 登入失敗,請重新登入。", 400) + identity = token.get("userinfo") or {} + user_id = identity.get("id") + session.clear() + if not can_read(user_id): + abort(403) + display_name = identity.get("name") + username = identity.get("preferred_username") + with closing(sqlite3.connect(access_database)) as connection, connection: + connection.execute( + "insert into user_profiles (user_id, display_name, username) values (?, ?, ?) " + "on conflict(user_id) do update set display_name = excluded.display_name, " + "username = excluded.username, last_login_at = CURRENT_TIMESTAMP", + ( + user_id, + display_name[:128] if isinstance(display_name, str) else None, + username[:64].lstrip("@") if isinstance(username, str) else None, + ), + ) + session.permanent = True + session["telegram_user_id"] = user_id + session["logout_token"] = secrets.token_urlsafe(24) + return redirect(url_for("reports")) + + @app.post("/logout") + def logout(): + check_csrf() + session.clear() + return redirect(url_for("login"), code=303) + + def render_maintainers(message: str = "", status: int = 200): + with closing(sqlite3.connect(access_database)) as connection: + connection.row_factory = sqlite3.Row + rows = connection.execute( + "select * from maintainers order by added_at desc, user_id" + ).fetchall() + profiles = { + row["user_id"]: row for row in connection.execute("select * from user_profiles") + } + return render_template( + "reports.html", + screen="maintainers", + profiles=profiles, + maintainers=rows, + administrators=sorted(app.config["ADMIN_TELEGRAM_IDS"]), + message=message, + ), status + + @app.get("/maintainers") + def maintainers(): + return render_maintainers() + + @app.post("/maintainers") + def add_maintainer(): + check_csrf() + raw_id = request.form.get("user_id", "").strip() + note = request.form.get("note", "").strip() + if len(note) > 80: + return render_maintainers("備註名稱最多 80 個字。", 400) + if not raw_id.isascii() or not raw_id.isdecimal() or len(raw_id) > 19: + return render_maintainers("請輸入有效的數字 Telegram user ID。", 400) + user_id = int(raw_id) + if not 0 < user_id < 2**63: + return render_maintainers("請輸入有效的數字 Telegram user ID。", 400) + if is_admin(user_id): + return render_maintainers("這個帳號已是後台管理者,無須加入維護者。", 400) + with closing(sqlite3.connect(access_database)) as connection, connection: + connection.execute( + "insert into maintainers (user_id, added_by, note) values (?, ?, ?) " + "on conflict(user_id) do nothing", + (user_id, session["telegram_user_id"], note), + ) + return redirect(url_for("maintainers"), code=303) + + @app.post("/maintainers//note") + def update_maintainer_note(user_id: int): + check_csrf() + note = request.form.get("note", "").strip() + if not 0 < user_id < 2**63 or len(note) > 80: + return render_maintainers("備註名稱最多 80 個字,請確認帳號 ID。", 400) + with closing(sqlite3.connect(access_database)) as connection, connection: + result = connection.execute( + "update maintainers set note = ? where user_id = ?", + (note, user_id), + ) + if not result.rowcount: + abort(404) + return redirect(url_for("maintainers"), code=303) + + @app.post("/maintainers//remove") + def remove_maintainer(user_id: int): + check_csrf() + if not 0 < user_id < 2**63 or is_admin(user_id): + abort(400) + with closing(sqlite3.connect(access_database)) as connection, connection: + connection.execute("delete from maintainers where user_id = ?", (user_id,)) + return redirect(url_for("maintainers"), code=303) + + @app.get("/") + def reports(): + filters = {name: request.args.get(name, "") for name in _FILTERS} + if ( + filters["reason"] not in {"", *REPORT_REASONS} + or filters["parser_source"] not in {"", *PARSER_SOURCES} + or any(len(value) > 128 for value in filters.values()) + ): + abort(400) + page = request.args.get("page", 1, type=int) + if not 1 <= page <= 100_000: + abort(400) + active = {name: value for name, value in filters.items() if value} + where = " where " + " and ".join(f"{name} = ?" for name in active) if active else "" + with closing(read_database()) as connection: + rows = connection.execute( + "select draft_id, reported_at, reason, parser_source, provider, model, " + "bot_version, attachment_json is not null as has_attachment " + f"from reminder_parse_reports{where} " + "order by reported_at desc, draft_id desc limit ? offset ?", + (*active.values(), _PAGE_SIZE + 1, (page - 1) * _PAGE_SIZE), + ).fetchall() + models = [ + row[0] + for row in connection.execute( + "select distinct model from reminder_parse_reports " + "where model is not null order by model" + ) + ] + versions = [ + row[0] + for row in connection.execute( + "select distinct bot_version from reminder_parse_reports order by bot_version" + ) + ] + return render_template( + "reports.html", + screen="list", + rows=rows[:_PAGE_SIZE], + reasons=REPORT_REASONS, + sources=PARSER_SOURCES, + models=models, + versions=versions, + filters=filters, + detail_query={**active, **({"page": page} if page > 1 else {})}, + page=page, + previous=url_for("reports", **active, page=page - 1) if page > 1 else None, + following=url_for("reports", **active, page=page + 1) + if len(rows) > _PAGE_SIZE and page < 100_000 + else None, + ) + + @app.get("/reports/") + def report_detail(draft_id: str): + with closing(read_database()) as connection: + row = connection.execute( + "select * from reminder_parse_reports where draft_id = ?", (draft_id,) + ).fetchone() + if row is None: + abort(404) + attachment = json.loads(row["attachment_json"]) if row["attachment_json"] else {} + return render_template( + "reports.html", + screen="detail", + report=row, + reasons=REPORT_REASONS, + sources=PARSER_SOURCES, + list_url=url_for( + "reports", + **{ + name: request.args[name] + for name in (*_FILTERS, "page") + if request.args.get(name) + }, + ), + original_text=attachment.get("source_text"), + attachment={ + label: attachment[field] + for field, label in ATTACHMENT_FIELDS.items() + if field in attachment and field != "source_text" + }, + ) + + return app diff --git a/src/remindly/app.py b/src/remindly/app.py index 0b8d812..66530eb 100644 --- a/src/remindly/app.py +++ b/src/remindly/app.py @@ -18,6 +18,7 @@ from remindly.reminders.service import ReminderService from remindly.settings import Settings, load_settings from remindly.storage.migrations import latest_schema_version +from remindly.storage.reports import ReportStore from remindly.storage.session_stores import SqliteDraftStore, SqliteEditSessionStore from remindly.storage.sqlite import ReminderRepository from remindly.telegram.client import BotCommand, TelegramClient @@ -96,6 +97,7 @@ def main() -> None: bot_username=settings.bot_username, default_timezone=settings.default_timezone, ai_commands=AiCommands(client, ai_gateway, settings.bot_username), + report_store=ReportStore(repository), ) scheduler = ReminderScheduler( repository=repository, diff --git a/src/remindly/bot/report_commands.py b/src/remindly/bot/report_commands.py new file mode 100644 index 0000000..80beebe --- /dev/null +++ b/src/remindly/bot/report_commands.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import re +from datetime import datetime + +from remindly.bot.response_sender import ResponseSender +from remindly.reminders.callback_data import CallbackData, CallbackDataError +from remindly.reminders.reports import ( + ATTACHMENT_FIELDS, + PARSER_SOURCES, + REPORT_REASONS, + parser_source, + report_attachment, +) +from remindly.reminders.text import command_body +from remindly.storage.reports import ReportStore +from remindly.telegram.client import TelegramClient +from remindly.telegram.models import TelegramCallbackQuery, TelegramMessage + +_UNAVAILABLE = "這張確認卡已失效,或你不是草稿建立者。" +_REPORT_TARGET = re.compile(r"draft_[A-Za-z0-9_-]{16}_[0-9a-f]{10}") + + +class ReportCommands: + def __init__( + self, client: TelegramClient, store: ReportStore, responses: ResponseSender + ) -> None: + self._client = client + self._store = store + self._responses = responses + + def handle_start( + self, message: TelegramMessage, text: str, bot_username: str | None, now: datetime + ) -> bool: + body = command_body(text, "start", bot_username) + if body is None or not body.startswith("report_"): + return False + target = body.removeprefix("report_") + if message.chat.type != "private" or message.from_user is None: + return True + draft = None + if _REPORT_TARGET.fullmatch(target): + draft_id, revision = target.rsplit("_", 1) + draft = self._store.get_draft(draft_id, revision, message.from_user.id, now) + if draft is None: + self._client.send_message(message.chat.id, _UNAVAILABLE) + else: + self._show_reasons(message.chat.id, None, target) + return True + + def handle_callback(self, callback: TelegramCallbackQuery, now: datetime) -> bool: + if not callback.data.startswith("report:"): + return False + message = callback.message + if message is None or message.chat.type != "private": + self._client.answer_callback_query(callback.id, "請在 Bot 私聊中回報。") + return True + try: + data = CallbackData.decode(callback.data) + except CallbackDataError: + self._client.answer_callback_query(callback.id, _UNAVAILABLE) + return True + if not _REPORT_TARGET.fullmatch(data.target_id): + self._client.answer_callback_query(callback.id, _UNAVAILABLE) + return True + + draft_id, revision = data.target_id.rsplit("_", 1) + user_id = callback.from_user.id + username = callback.from_user.username + chat_id, message_id = message.chat.id, message.id + reason, _, attach = (data.value or "").partition(",") + if data.action == "send_id" and reason in REPORT_REASONS and attach in {"0", "1"}: + result = self._store.submit( + draft_id, + revision, + user_id, + now, + reporter_username=username, + reason=reason, + include_attachment=attach == "1", + ) + if result is None: + self._client.answer_callback_query(callback.id, _UNAVAILABLE) + return True + self._client.answer_callback_query(callback.id) + text = "已收到回報。" if result else "這張確認卡已回報過。" + self._responses.edit_or_send( + chat_id, + message_id, + text + "\n你可以回原本的聊天確認或取消提醒。", + reply_markup={"inline_keyboard": []}, + ) + return True + + draft = self._store.get_draft(draft_id, revision, user_id, now) + if draft is None: + self._client.answer_callback_query(callback.id, _UNAVAILABLE) + return True + + self._client.answer_callback_query(callback.id) + if data.action == "back": + self._show_reasons(chat_id, message_id, data.target_id) + # A send button issued before the disclosure must open the updated preview first. + elif ( + data.action in {"preview", "send"} and reason in REPORT_REASONS and attach in {"0", "1"} + ): + included = attach == "1" + lines = [ + "送出回報前確認", + f"原因:{REPORT_REASONS[reason]}", + f"解析來源:{PARSER_SOURCES[parser_source(draft)]}", + f"Bot 版本:{draft.parse_result['report_bot_version']}", + ] + if parser_source(draft) == "ai": + lines.append( + f"供應商/模型:{draft.parse_result.get('provider') or '未知'}" + f"/{draft.parse_result.get('model') or '未知'}" + ) + if included: + lines.extend( + f"{ATTACHMENT_FIELDS[field]}:{value}" + for field, value in report_attachment(draft).items() + ) + else: + lines.append("目前未附原句和解析結果。") + lines.extend( + [ + "", + "送出時會記錄下列身分資訊,維護者可在回報詳情查看。", + f"Telegram user ID:{user_id}", + f"Telegram @username:{'@' + username if username else '未設定'}", + ] + ) + self._responses.edit_or_send( + chat_id, + message_id, + "\n".join(lines), + reply_markup={ + "inline_keyboard": [ + [ + _button( + "移除附件" if included else "附上原句與結果", + "preview", + data.target_id, + f"{reason},{int(not included)}", + ) + ], + [ + _button("返回", "back", data.target_id), + _button("送出回報", "send_id", data.target_id, f"{reason},{attach}"), + ], + ] + }, + ) + return True + + def _show_reasons(self, chat_id: int, message_id: int | None, target: str) -> None: + buttons = [ + _button(label, "preview", target, f"{reason},0") + for reason, label in REPORT_REASONS.items() + ] + self._responses.edit_or_send( + chat_id, + message_id, + "回報解析問題\n哪裡解析錯了?", + reply_markup={"inline_keyboard": [buttons[:2], buttons[2:4], buttons[4:]]}, + ) + + +def _button(text: str, action: str, target: str, value: str | None = None) -> dict[str, str]: + return {"text": text, "callback_data": CallbackData("report", action, target, value).encode()} diff --git a/src/remindly/bot/response_sender.py b/src/remindly/bot/response_sender.py index d10b1ba..538451e 100644 --- a/src/remindly/bot/response_sender.py +++ b/src/remindly/bot/response_sender.py @@ -1,9 +1,10 @@ from __future__ import annotations import logging +import re from datetime import datetime -from remindly.reminders.models import RecurrenceError, Reminder +from remindly.reminders.models import RecurrenceError, Reminder, ReminderDraft from remindly.reminders.renderer import ( ReminderRenderer, back_to_list_keyboard, @@ -39,10 +40,15 @@ def __init__( client: TelegramClient, reminder_service: ReminderService, renderer: ReminderRenderer, + *, + report_links: bool = False, + bot_username: str | None = None, ) -> None: self._client = client self._reminder_service = reminder_service self._renderer = renderer + self._report_links = report_links + self._bot_username = bot_username def send_reminder_list( self, @@ -183,9 +189,12 @@ def send_draft_result( if result.draft.parse_result.get("ai_fallback"): notice = (notice or "") + "群組 AI 暫時無法使用,已改用內建解析。\n" if isinstance(result, Confirmation): + confirmation = self._renderer.render_confirmation( + result.draft, report_url=self._report_url(result.draft) + ) message_id = self._client.send_message( chat_id, - _prepend_notice(self._renderer.render_confirmation(result.draft), notice), + _prepend_notice(confirmation, notice), parse_mode="HTML", reply_markup=confirmation_keyboard(result.draft.id), ) @@ -200,6 +209,22 @@ def send_draft_result( if message_id is not None: self._reminder_service.set_draft_prompt_message_id(result.draft.id, message_id) + def _report_url(self, draft: ReminderDraft) -> str | None: + revision = draft.parse_result.get("report_revision") + if not self._report_links or not revision: + return None + if not self._bot_username: + try: + result = self._client.call("getMe", {}).get("result") + except TelegramApiError: + LOGGER.warning("Bot username lookup failed; omitting report link") + return None + username = result.get("username") if isinstance(result, dict) else None + if not isinstance(username, str) or not re.fullmatch(r"[A-Za-z0-9_]+", username): + return None + self._bot_username = username + return f"https://t.me/{self._bot_username}?start=report_{draft.id}_{revision}" + def send_edit_result( self, chat_id: int, diff --git a/src/remindly/bot/router.py b/src/remindly/bot/router.py index 2613082..319fc8a 100644 --- a/src/remindly/bot/router.py +++ b/src/remindly/bot/router.py @@ -8,10 +8,12 @@ from remindly.bot.ai_commands import AiCommands from remindly.bot.callback_handlers import CallbackHandlers from remindly.bot.command_handlers import CommandHandlers +from remindly.bot.report_commands import ReportCommands from remindly.bot.response_sender import ResponseSender from remindly.reminders.renderer import ReminderRenderer from remindly.reminders.service import ReminderService from remindly.reminders.text import looks_like_reminder_request, strip_bot_mention +from remindly.storage.reports import ReportStore from remindly.telegram.client import TelegramClient from remindly.telegram.models import TelegramMessage, TelegramUpdate @@ -26,11 +28,18 @@ def __init__( bot_username: str | None, default_timezone: str, ai_commands: AiCommands | None = None, + report_store: ReportStore | None = None, ) -> None: self._reminder_service = reminder_service self._bot_username = bot_username self._default_timezone = default_timezone - self._responses = ResponseSender(client, reminder_service, renderer) + self._responses = ResponseSender( + client, + reminder_service, + renderer, + report_links=report_store is not None, + bot_username=bot_username, + ) self._command_handlers = CommandHandlers( client, reminder_service, @@ -40,11 +49,16 @@ def __init__( self._callback_handlers = CallbackHandlers(client, reminder_service, self._responses) self._client = client self._ai_commands = ai_commands + self._reports = ( + ReportCommands(client, report_store, self._responses) if report_store else None + ) def handle_update(self, update: TelegramUpdate) -> None: """接收 Telegram update,依型別分派給 message 或 callback 流程。""" now = datetime.now(ZoneInfo(self._default_timezone)) if update.callback_query: + if self._reports and self._reports.handle_callback(update.callback_query, now): + return if self._ai_commands is not None and self._ai_commands.handle_callback( update.callback_query ): @@ -64,6 +78,8 @@ def _handle_message(self, message: TelegramMessage, now: datetime) -> None: 使用者意圖比 session state 更權威:若新訊息本身像新的提醒,優先斬斷舊 draft/edit。 """ text = strip_bot_mention(message.text, self._bot_username) + if self._reports and self._reports.handle_start(message, text, self._bot_username, now): + return if self._ai_commands is not None and self._ai_commands.handle(message): return self._reminder_service.record_message_context(message, now) diff --git a/src/remindly/reminders/renderer.py b/src/remindly/reminders/renderer.py index 16f5fe8..e60a3dd 100644 --- a/src/remindly/reminders/renderer.py +++ b/src/remindly/reminders/renderer.py @@ -16,7 +16,7 @@ class ReminderRenderer: - def render_confirmation(self, draft: ReminderDraft) -> str: + def render_confirmation(self, draft: ReminderDraft, *, report_url: str | None = None) -> str: participants = self.render_participants(draft.participants) used_ai = draft.parse_result.get("grain") == "ai" lines = ["確認建立提醒?"] @@ -32,7 +32,10 @@ def render_confirmation(self, draft: ReminderDraft) -> str: parser_name = ( str(draft.parse_result.get("model_display_name") or "AI") if used_ai else "內建規則" ) - lines.extend(["", f"使用 {html_escape(parser_name)} 解析"]) + footer = f"使用 {html_escape(parser_name)} 解析" + if report_url: + footer += f' · 回報問題' + lines.extend(["", footer]) return "\n".join(lines) def render_created(self, reminder: Reminder) -> str: diff --git a/src/remindly/reminders/reports.py b/src/remindly/reminders/reports.py new file mode 100644 index 0000000..a53c045 --- /dev/null +++ b/src/remindly/reminders/reports.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import secrets +from datetime import UTC, datetime + +from remindly import __version__ +from remindly.reminders.models import ReminderDraft +from remindly.reminders.recurrence import format_rule + +REPORT_REASONS = { + "time": "時間錯了", + "title": "事項錯了", + "participant": "對象錯了", + "recurrence": "重複設定錯了", + "other": "其他", +} + +PARSER_SOURCES = { + "ai": "AI", + "rules": "內建規則", + "rules_fallback": "AI 降級後使用內建規則", +} + +ATTACHMENT_FIELDS = { + "source_text": "原句", + "title": "解析事項", + "remind_at": "解析時間", + "participants": "解析對象", + "recurrence": "重複設定", + "timezone": "時區", + "parsed_at": "解析當下", +} + + +def stamp_report_context(draft: ReminderDraft, now: datetime) -> None: + draft.parse_result.update( + report_revision=secrets.token_hex(5), + report_parsed_at=now.astimezone(UTC).isoformat(), + report_bot_version=__version__, + ) + + +def parser_source(draft: ReminderDraft) -> str: + if draft.parse_result.get("grain") == "ai": + return "ai" + return "rules_fallback" if draft.parse_result.get("ai_fallback") else "rules" + + +def report_attachment(draft: ReminderDraft) -> dict[str, str]: + # Keep the exact preview within Telegram's message limit, even for long originals. + return { + "source_text": _excerpt(draft.source_text, 750), + "title": _excerpt(draft.title or "", 500), + "remind_at": draft.remind_at.isoformat() if draft.remind_at else "", + "participants": _excerpt(", ".join(p.display_name for p in draft.participants), 250), + "recurrence": _excerpt(format_rule(draft.recurrence), 250) if draft.recurrence else "無", + "timezone": draft.timezone, + "parsed_at": str(draft.parse_result["report_parsed_at"]), + } + + +def _excerpt(value: str, limit: int) -> str: + return value if len(value) <= limit else value[: limit - 1] + "…" diff --git a/src/remindly/reminders/service.py b/src/remindly/reminders/service.py index 241d8a6..ccca54c 100644 --- a/src/remindly/reminders/service.py +++ b/src/remindly/reminders/service.py @@ -24,6 +24,7 @@ ) from remindly.reminders.parser import ReminderParser, dedupe_participants from remindly.reminders.recurrence import next_fire +from remindly.reminders.reports import stamp_report_context from remindly.reminders.repositories import ReminderRepository from remindly.reminders.text import looks_like_reminder_request from remindly.telegram.models import ( @@ -291,11 +292,10 @@ def begin_create( draft.missing_fields = ["title", "time"] draft.parse_result["question"] = "請描述要建立的提醒,例如明天 15:00 叫我拿包裹。" + self._save_creation_draft(draft, now) if draft.is_complete: - self._draft_store.save(draft, now) return Confirmation(draft) - self._draft_store.save(draft, now) return DraftPrompt( draft=draft, question=self._question_for(draft), @@ -317,11 +317,10 @@ def continue_draft( return None self._merge_answer(draft, message.text, message, now) + self._save_creation_draft(draft, now) if draft.is_complete: - self._draft_store.save(draft, now) return Confirmation(draft) - self._draft_store.save(draft, now) return DraftPrompt( draft=draft, question=self._question_for(draft), @@ -349,11 +348,16 @@ def apply_quick_time( if not parsed.remind_at or parsed.missing_time or parsed.is_past: draft.missing_fields.append("time") - self._draft_store.save(draft, now) + self._save_creation_draft(draft, now) if draft.is_complete: return Confirmation(draft) return DraftPrompt(draft=draft, question=self._question_for(draft)) + def _save_creation_draft(self, draft: ReminderDraft, now: datetime) -> None: + if draft.is_complete: + stamp_report_context(draft, now) + self._draft_store.save(draft, now) + def confirm(self, draft_id: str, user_id: int, now: datetime) -> CreateResult | None: draft = self._draft_store.get_by_id(draft_id, now) if not draft or draft.creator_user_id != user_id or not draft.is_complete: diff --git a/src/remindly/static/report-filters.js b/src/remindly/static/report-filters.js new file mode 100644 index 0000000..228ecb0 --- /dev/null +++ b/src/remindly/static/report-filters.js @@ -0,0 +1,7 @@ +const reportFilters = document.getElementById('report-filters'); + +for (const field of reportFilters.querySelectorAll('select')) { + field.addEventListener('change', () => reportFilters.requestSubmit()); +} + +reportFilters.querySelector('button[type="submit"]').hidden = true; diff --git a/src/remindly/static/reports.css b/src/remindly/static/reports.css new file mode 100644 index 0000000..4d1bf18 --- /dev/null +++ b/src/remindly/static/reports.css @@ -0,0 +1,249 @@ +:root { + color-scheme: light dark; + font: 16px/1.55 system-ui, -apple-system, "PingFang TC", "Microsoft JhengHei", sans-serif; + --canvas: light-dark(#f5f6fa, #15161e); + --surface: light-dark(#fff, #1d1f2a); + --subtle: light-dark(#f8f9fc, #222532); + --hover: light-dark(#f1f2fa, #2a2d3e); + --text: light-dark(#191d30, #f0f1fa); + --muted: light-dark(#545d73, #aeb6ce); + --border: light-dark(#dfe3ed, #383e52); + --control-border: light-dark(#b4bfd1, #66718b); + --accent: light-dark(#4736ad, #c0b9ff); + --accent-bg: light-dark(#eeebff, #322b54); + --primary: light-dark(#4a39b5, #b9aeff); + --primary-hover: light-dark(#39288f, #d0c8ff); + --on-primary: light-dark(#fff, #1d153d); + --fallback-ink: light-dark(#73500d, #f6d58e); + --fallback-bg: light-dark(#fff3d9, #423322); + --file-ink: light-dark(#17634f, #a3e9d3); + --file-bg: light-dark(#e4f6ee, #213b35); + --shadow-color: light-dark(#18213b08, #080a1566); +} +* { box-sizing: border-box; } +body { margin: 0; color: var(--text); background: var(--canvas); } +h1, h2, h3, p, dl, dd, blockquote { margin: 0; } +a { color: var(--accent); text-underline-offset: .18em; } +button, select { font: inherit; color: inherit; } +a, button, select { touch-action: manipulation; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +.icon { width: 1rem; height: 1rem; flex: 0 0 auto; vertical-align: middle; } +.shell { max-width: 76rem; margin-inline: auto; padding-inline: clamp(1rem, 4vw, 3rem); } +.skip-link { + position: absolute; z-index: 1; top: .5rem; inset-inline-start: .5rem; + padding: .75rem 1rem; background: var(--surface); translate: 0 -150%; +} +.skip-link:focus { translate: 0; } + +.site-header { background: var(--surface); border-bottom: 1px solid var(--border); } +.header-inner { + min-height: 4rem; display: flex; align-items: center; justify-content: space-between; gap: .5rem 1rem; +} +.brand, .header-nav { display: flex; align-items: center; gap: .65rem; } +.brand { flex-shrink: 0; } +.brand strong { display: block; font-weight: 700; line-height: 1.2; } +.brand-subtitle { display: block; font-size: .75rem; color: var(--muted); } +.brand-mark, .feature-icon { + display: grid; place-items: center; flex: 0 0 auto; width: 2.25rem; height: 2.25rem; + border-radius: .65rem; background: var(--accent-bg); color: var(--accent); +} +.brand-mark { background: var(--primary); color: var(--on-primary); } +.brand-mark .icon, .feature-icon .icon { width: 1.25rem; height: 1.25rem; } +.header-nav form { margin: 0; } +.workspace-link, .button, .back-link { + display: inline-flex; align-items: center; justify-content: center; gap: .45rem; + min-height: 2.75rem; border: 1px solid transparent; border-radius: .55rem; + padding: .45rem .8rem; font: inherit; font-size: .875rem; font-weight: 650; + text-decoration: none; cursor: pointer; + transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease; +} +.workspace-link { color: var(--accent); background: var(--accent-bg); } +.button-quiet { color: var(--muted); background: transparent; } +.button-secondary { color: var(--text); background: var(--surface); border-color: var(--control-border); } +.button-primary { color: var(--on-primary); background: var(--primary); } +.button-primary:hover, .button-primary:active { background: var(--primary-hover); } +.button-quiet:hover, .button-secondary:hover, .workspace-link:hover, .back-link:hover, +.button-quiet:active, .button-secondary:active, .workspace-link:active, .back-link:active { + background: var(--hover); color: var(--accent); +} + +main { padding-block: clamp(1.75rem, 4vw, 3rem) 3rem; } +.page-heading { margin-bottom: 1.4rem; } +.eyebrow { + display: flex; align-items: center; gap: .45rem; margin-bottom: .45rem; + color: var(--accent); font-size: .8125rem; font-weight: 700; letter-spacing: .035em; +} +h1 { font-size: clamp(1.9rem, 4vw, 2.7rem); line-height: 1.18; letter-spacing: -.035em; } +.page-heading p, .login p { margin-top: .55rem; color: var(--muted); } +h2 { font-size: .9375rem; font-weight: 700; } +h3 { font-size: .875rem; font-weight: 650; } +.panel { border: 1px solid var(--border); border-radius: .8rem; background: var(--surface); box-shadow: 0 2px 4px var(--shadow-color); } +.filter-panel { margin-bottom: 1.25rem; padding: 1rem 1.125rem; } +.section-title { display: flex; align-items: center; gap: .5rem; } +.section-title > .icon { color: var(--accent); } +.filters { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: end; gap: .7rem; margin-top: .75rem; +} +.filter-field { display: grid; gap: .28rem; min-width: 0; font-size: .8125rem; font-weight: 650; } +.filter-control { position: relative; min-width: 0; } +.filter-control select { + appearance: none; display: block; width: 100%; min-width: 0; min-height: 2.75rem; + padding: .45rem 2.25rem .45rem .8rem; border: 1px solid var(--control-border); + border-radius: .55rem; background: var(--surface); font-size: 1rem; font-weight: 500; + text-overflow: ellipsis; cursor: pointer; +} +.filter-control select:hover { border-color: var(--accent); } +.filter-control::after { + content: ""; width: .45rem; height: .45rem; color: var(--muted); + border: solid currentColor; border-width: 0 1.5px 1.5px 0; rotate: 45deg; + position: absolute; inset-inline-end: .95rem; top: calc(50% - .35rem); pointer-events: none; +} +@supports (appearance: base-select) and selector(select::picker(select)) { + .filter-control select, .filter-control select::picker(select) { appearance: base-select; } + .filter-control::after { display: none; } + .filter-control select { display: flex; align-items: center; gap: .6rem; padding-inline-end: .9rem; } + .filter-control select::picker-icon { + content: ""; width: .45rem; height: .45rem; color: var(--muted); + border: solid currentColor; border-width: 0 1.5px 1.5px 0; rotate: 45deg; + margin-block-start: -.2rem; flex-shrink: 0; + } + .filter-control select:open { border-color: var(--accent); } + .filter-control select:open::picker-icon { rotate: 225deg; margin-block-start: .2rem; } + .filter-control select::picker(select) { + margin-block: .35rem; max-inline-size: calc(100vw - 2rem); max-block-size: 18rem; + padding: .3rem; border: 1px solid var(--border); border-radius: .65rem; + background: var(--surface); color: var(--text); + box-shadow: 0 .5rem 1.5rem var(--shadow-color); + } + .filter-control option { + min-height: 2.75rem; padding: .45rem .65rem; border-radius: .4rem; + font-size: .875rem; overflow-wrap: anywhere; cursor: pointer; + } + .filter-control option:hover, .filter-control option:focus-visible { background: var(--hover); } + .filter-control option:checked { background: var(--accent-bg); color: var(--accent); font-weight: 650; } +} +.filter-actions { grid-column: 1 / -1; display: flex; justify-content: flex-end; gap: .5rem; } +.filter-actions button[hidden] { display: none; } +@media (min-width: 57rem) { + .filters { grid-template-columns: repeat(2, minmax(8rem, 1fr)) minmax(10rem, 1.35fr) minmax(7rem, .8fr) auto; } + .filter-actions { grid-column: auto; } +} + +.panel-header { + display: flex; align-items: center; justify-content: space-between; gap: .4rem .8rem; + flex-wrap: wrap; min-height: 3.25rem; padding: .65rem 1.125rem; + border-bottom: 1px solid var(--border); +} +.panel-header h2 { display: inline-flex; align-items: center; gap: .65rem; } +.panel-header.section-title { justify-content: flex-start; } +.result-count { + color: var(--accent); background: var(--accent-bg); border-radius: .35rem; + padding: .12rem .42rem; font-size: .75rem; font-weight: 650; white-space: nowrap; +} +.list-order, .timezone, .muted { color: var(--muted); } +.list-order, .timezone { font-size: .75rem; } +.report-table { width: 100%; border-collapse: collapse; text-align: start; font-size: .875rem; } +.report-table th, .report-table td { padding: .55rem 1.125rem; text-align: start; } +.report-table th { + background: var(--subtle); color: var(--muted); font-size: .75rem; font-weight: 650; + border-bottom: 1px solid var(--border); +} +.report-table tr + tr td { border-top: 1px solid var(--border); } +.report-table tbody tr { transition: background-color 160ms ease; } +.report-table tbody tr:hover, .report-table tbody tr:focus-within { background: var(--subtle); } +.report-link { + display: inline-flex; align-items: center; gap: .4rem; min-height: 2.75rem; color: var(--text); + text-decoration: none; font-weight: 700; +} +.report-link .icon { width: .85rem; height: .85rem; color: var(--accent); } +.report-link:hover, .report-link:focus-visible { color: var(--accent); text-decoration: underline; } +.source-badge, .attachment-badge { + display: inline-flex; width: fit-content; align-items: center; gap: .35rem; + border-radius: .4rem; padding: .15rem .45rem; font-size: .75rem; font-weight: 650; +} +.source-ai { color: var(--accent); background: var(--accent-bg); } +.source-rules { color: var(--muted); background: var(--hover); } +.source-rules_fallback { color: var(--fallback-ink); background: var(--fallback-bg); } +.badge-dot { width: .375rem; height: .375rem; border-radius: 50%; background: currentColor; flex-shrink: 0; } +.attachment-badge { color: var(--file-ink); background: var(--file-bg); white-space: nowrap; } +.attachment-badge .icon { width: .875rem; height: .875rem; } +.model-name { display: block; color: var(--muted); margin-top: .25rem; font-size: .75rem; overflow-wrap: anywhere; } +.time-cell time, .detail-subtitle time { font-variant-numeric: tabular-nums; white-space: nowrap; color: var(--muted); } +.version, code { font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; } +.version { color: var(--muted); font-size: .8125rem; } +.mobile-label, .mobile-utc { display: none; } + +.empty-state { display: grid; justify-items: center; gap: .75rem; padding: 2rem 1rem; text-align: center; color: var(--muted); } +.pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .75rem; padding: .75rem 1rem; border-top: 1px solid var(--border); } +.back-link { margin-inline-start: -.65rem; padding-inline: .65rem; color: var(--accent); } +.detail-heading { margin-top: .45rem; } +.detail-subtitle { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem .65rem; margin-top: .75rem; font-size: .875rem; } +.detail-grid, .detail-sidebar { display: grid; align-items: start; gap: 1rem; } +.panel-body { padding: 1rem 1.125rem; } +blockquote { + white-space: pre-wrap; overflow-wrap: anywhere; margin-top: .5rem; padding: .75rem 1rem; + border-inline-start: 3px solid var(--accent); border-radius: .2rem .5rem .5rem .2rem; + background: var(--accent-bg); +} +blockquote + h3 { margin-top: 1.25rem; } +.metadata { display: grid; grid-template-columns: minmax(5.4rem, max-content) minmax(0, 1fr); gap: .55rem 1rem; font-size: .875rem; } +h3 + .metadata { margin-top: .65rem; } +.metadata dt { color: var(--muted); } +.metadata dd { overflow-wrap: anywhere; white-space: pre-wrap; } +.login { max-width: 28rem; margin: clamp(1rem, 5vw, 4rem) auto; padding: clamp(1.25rem, 5vw, 2rem); } +.login h1 { margin-top: .85rem; } +.login .button { margin-top: 1.25rem; } +@media (min-width: 49rem) { + .detail-grid { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); } +} +@media (max-width: 48rem) { + .report-table, .report-table tbody { display: block; } + .report-table thead { + position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; + } + .report-table tbody tr { + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .3rem .75rem; padding: .5rem 1rem .85rem; + } + .report-table tbody tr + tr { border-top: 1px solid var(--border); } + .report-table td, .report-table tr + tr td { display: block; padding: 0; border: 0; } + .reason-cell, .source-cell { grid-column: 1 / -1; } + .report-link { display: flex; justify-content: space-between; } + .time-cell { grid-column: 1 / -1; margin-top: .1rem; font-size: .8125rem; } + .report-table .attachment-cell { text-align: end; } + .mobile-label, .mobile-utc { display: inline; color: var(--muted); font-size: .75rem; } +} +@media (max-width: 30rem) { + .workspace-link { display: none; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { transition-duration: 0s !important; } +} + +.member-form { display: flex; align-items: end; flex-wrap: wrap; gap: .75rem; margin-block: 1rem .65rem; } +.member-form .filter-field { flex: 1; min-width: 12rem; } +.member-form input { min-height: 2.75rem; width: 100%; border: 1px solid var(--control-border); border-radius: .55rem; padding: .5rem .75rem; font: inherit; color: var(--text); background: var(--surface); } +.member-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 1rem; padding: 1.1rem; border-top: 1px solid var(--border); overflow-wrap: anywhere; } +.member-row p, .admin-notice p, #member-help { font-size: .8125rem; } +.remove-confirm { padding: 1rem; background: var(--subtle); border-top: 1px solid var(--border); } +.remove-confirm p { margin-bottom: .75rem; } +.admin-notice { margin-top: 1.25rem; padding: 1.1rem; } +.admin-notice p { margin-top: .5rem; overflow-wrap: anywhere; } +@media (max-width: 600px) { .header-inner { flex-wrap: wrap; padding-block: .5rem; } .header-nav { flex-wrap: wrap; } } + +.member-identity { display: grid; gap: .5rem; min-width: 0; width: 100%; } +.member-identity > p { line-height: 1.7; } +.member-note-editor { margin-top: 0; } +.member-note-editor summary { cursor: pointer; color: var(--accent); font-size: .8125rem; width: fit-content; padding-block: .3rem; } +.admin-identity { margin-block: .85rem; } + +.person-heading { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem; } + +.identity-pills { display: contents; } +.identity-pill { display: inline-flex; align-items: center; gap: .3rem; max-width: 100%; padding: .15rem .6rem; border-radius: 999px; font-size: .75rem; line-height: 1.5; color: var(--muted); background: var(--subtle); border: 1px solid var(--border); overflow-wrap: anywhere; } + +@media (max-width: 600px) { + .member-row { grid-template-columns: minmax(0, 1fr); gap: .65rem; } + .member-row > .button { justify-self: end; } +} diff --git a/src/remindly/storage/migrations.py b/src/remindly/storage/migrations.py index 398eeaf..41208e1 100644 --- a/src/remindly/storage/migrations.py +++ b/src/remindly/storage/migrations.py @@ -192,6 +192,34 @@ class Migration: end; """, ), + Migration( + version=11, + name="reminder_parse_reports", + sql=""" + create table reminder_parse_reports ( + draft_id text primary key not null, + reported_at text not null, + reason text not null check ( + reason in ('time', 'title', 'participant', 'recurrence', 'other') + ), + parser_source text not null check (parser_source in ('ai', 'rules', 'rules_fallback')), + provider text, + model text, + bot_version text not null, + attachment_json text + ); + create index idx_reminder_parse_reports_recent + on reminder_parse_reports(reported_at desc, draft_id desc); + """, + ), + Migration( + version=12, + name="parse_report_reporter", + sql=""" + alter table reminder_parse_reports add column reporter_user_id integer; + alter table reminder_parse_reports add column reporter_username text; + """, + ), ) diff --git a/src/remindly/storage/reports.py b/src/remindly/storage/reports.py new file mode 100644 index 0000000..6d6df21 --- /dev/null +++ b/src/remindly/storage/reports.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import json +import sqlite3 +from datetime import UTC, datetime + +from remindly.reminders.models import ReminderDraft +from remindly.reminders.reports import REPORT_REASONS, parser_source, report_attachment +from remindly.storage.session_stores import row_to_draft +from remindly.storage.sqlite import ReminderRepository + + +class ReportStore: + def __init__(self, repository: ReminderRepository) -> None: + self._repository = repository + + def get_draft( + self, draft_id: str, revision: str, user_id: int, now: datetime + ) -> ReminderDraft | None: + with self._repository.connect() as connection: + return self._get_draft(connection, draft_id, revision, user_id, now) + + def submit( + self, + draft_id: str, + revision: str, + user_id: int, + now: datetime, + *, + reporter_username: str | None, + reason: str, + include_attachment: bool, + ) -> bool | None: + """Return True on insert, False on duplicate, None for an unavailable draft.""" + if reason not in REPORT_REASONS: + return None + with self._repository.connect() as connection: + connection.execute("begin immediate") + draft = self._get_draft(connection, draft_id, revision, user_id, now) + if draft is None: + return None + source = parser_source(draft) + result = draft.parse_result + inserted = connection.execute( + """insert into reminder_parse_reports + (draft_id, reported_at, reason, parser_source, + provider, model, bot_version, attachment_json, reporter_user_id, reporter_username) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(draft_id) do nothing""", + ( + draft.id, + now.astimezone(UTC).isoformat(), + reason, + source, + result.get("provider") if source == "ai" else None, + result.get("model") if source == "ai" else None, + result["report_bot_version"], + json.dumps(report_attachment(draft), ensure_ascii=False) + if include_attachment + else None, + user_id, + reporter_username or None, + ), + ) + return inserted.rowcount == 1 + + @staticmethod + def _get_draft( + connection: sqlite3.Connection, draft_id: str, revision: str, user_id: int, now: datetime + ) -> ReminderDraft | None: + row = connection.execute( + "select * from reminder_drafts where id = ? and creator_user_id = ?", + (draft_id, user_id), + ).fetchone() + if row is None: + return None + draft = row_to_draft(row) + if ( + not draft.is_complete + or draft.expires_at is None + or draft.expires_at <= now + or draft.parse_result.get("report_revision") != revision + ): + return None + return draft diff --git a/src/remindly/templates/reports.html b/src/remindly/templates/reports.html new file mode 100644 index 0000000..1af6636 --- /dev/null +++ b/src/remindly/templates/reports.html @@ -0,0 +1,289 @@ +{% macro icon(name) -%} + +{%- endmacro %} +{% macro select_filter(name, label, options, value) -%} + +{%- endmacro %} +{% macro identity_pills(user_id, profile) -%} +
+ {% if profile and profile.username %}@{{ profile.username }}{% endif %} + ID {{ user_id }} +
+{%- endmacro %} +{% macro source_badge(source) -%} +{{ sources[source] }} +{%- endmacro %} + + + + + + + {% if screen == 'detail' %}{{ reasons[report.reason] }} · {% endif %}解析回報 · Remindly + + {% if screen == 'list' %} + + {% endif %} + + + + +
+ {% if screen == 'login' or screen == 'error' %} + + {% elif screen == 'maintainers' %} +
+
ACCESS MANAGEMENT

人員權限

+

新增維護者即可查閱全部回報與回報者資訊;移除後,下次請求立即失效。

+
+
+

新增後台維護者

+
+ + + + +
+

備註由管理者填寫;Telegram 名稱與帳號會在本人登入後取得,之後每次登入更新。權限仍依數字 ID 判斷。

+ {% if message %}{% endif %} +
+
+

後台維護者 {{ maintainers|length }} 人

+ {% for member in maintainers %} +
+ {% set profile = profiles.get(member.user_id) %} + {% set grantor = profiles.get(member.added_by) %} +
+
{{ (profile.display_name if profile else '') or member.note or '未命名帳號' }}{{ identity_pills(member.user_id, profile) }}
+ {% if member.note %}

管理者備註:{{ member.note }}

{% endif %} +

由 {{ (grantor.display_name if grantor else '') or '管理者' }}({{ member.added_by }})新增 · {{ member.added_at }} UTC

+
+ 編輯備註 +
+ + + +
+
+
+ 移除 +
+ {% if request.args.get('remove') == member.user_id|string %} +
+ +

確定移除 {{ (profile.display_name if profile else '') or member.note or '未命名帳號' }}(ID {{ member.user_id }})?此帳號將無法再查閱回報。

+ + 取消 +
+ {% endif %} + {% else %}

還沒有維護者。管理者仍可查閱回報。

{% endfor %} +
+
+

後台管理者

+ {% for user_id in administrators %} + {% set profile = profiles.get(user_id) %} +
{{ (profile.display_name if profile else '') or '後台管理者' }}{{ identity_pills(user_id, profile) }}
+
+ {% else %}

未設定

{% endfor %} +

由部署設定 ADMIN_TELEGRAM_IDS 指定,可查閱回報與管理維護者。變更管理者須更新部署設定,無法在此移除。

+
+ {% elif screen == 'list' %} +
+
{{ icon('inbox') }} 回報收件匣
+

解析回報

+

查看使用者在確認提醒時回報的解析問題。

+
+ +
+
+ {{ icon('filter') }}

篩選回報

+
+
+ {{ select_filter('reason', '原因', reasons, filters.reason) }} + {{ select_filter('parser_source', '解析來源', sources, filters.parser_source) }} + {{ select_filter('model', '模型', models, filters.model) }} + {{ select_filter('bot_version', 'Bot 版本', versions, filters.bot_version) }} +
+ 清除 + +
+
+
+ +
+
+

回報列表 本頁 {{ rows|length }} 筆

+ 依回報時間排序 +
+ {% if rows %} + + + + + + + + + + + + {% for row in rows %} + + + + + + + + {% endfor %} + +
回報原因解析來源/模型回報時間 UTCBot 版本附件
+ + {{ reasons[row.reason] }} {{ icon('next') }} + + + {{ source_badge(row.parser_source) }} + {% if row.model %}{{ row.provider or '' }}{% if row.provider %} · {% endif %}{{ row.model }}{% endif %} + {{ row.bot_version }} + {% if row.has_attachment %}{{ icon('attachment') }} 有附件 + {% else %}無附件{% endif %} +
+ {% else %} +
+ {{ icon('inbox') }} +

目前沒有符合條件的回報。

+ {% if detail_query %}清除篩選{% endif %} +
+ {% endif %} + {% if previous or following %} + + {% endif %} +
+ {% else %} + {{ icon('back') }} 回到回報列表 +
+
回報詳情
+

{{ reasons[report.reason] }}

+
+ {{ source_badge(report.parser_source) }} + +
+
+ +
+
+
+ {{ icon('attachment') }}

回報附件

+
+ {% if original_text or attachment %} +
+ {% if original_text %} +

原句

+
{{ original_text }}
+ {% endif %} + {% if attachment %} +

解析結果

+ + {% endif %} +
+ {% else %} +
+ {{ icon('attachment') }} +

使用者未附上原句與解析結果。

+
+ {% endif %} +
+
+
+

回報者

+ {% if report.reporter_user_id %} + + {% else %} +

這筆回報未記錄回報者。

+ {% endif %} +
+
+

解析紀錄

+ +
+
+
+ {% endif %} +
+ + diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..6f25daf --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import json +import os +import sqlite3 +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +from joserfc import jwt +from joserfc.jwk import RSAKey +from requests import Response + +from remindly.admin import create_app +from remindly.storage.sqlite import ReminderRepository + +ORIGIN = "https://reports.example.test" + + +class AdminTest(unittest.TestCase): + def setUp(self) -> None: + directory = Path(self.enterContext(tempfile.TemporaryDirectory())) + repository = ReminderRepository(directory / "reports.db") + repository.migrate() + with repository.connect() as connection: + connection.executemany( + """insert into reminder_parse_reports + (draft_id, reported_at, reason, parser_source, + provider, model, bot_version, attachment_json, reporter_user_id, reporter_username) + values (?, '2026-11-10T10:00:00+00:00', ?, ?, ?, ?, ?, ?, ?, ?)""", + [ + ( + "draft_ai", + "time", + "ai", + "groq", + "test-model", + "1.0", + json.dumps({"source_text": ""}), + 123456789, + "reporter_demo", + ), + ("draft_rules", "title", "rules", None, None, "2.0", None, None, None), + ("draft_id_only", "other", "rules", None, None, "2.0", None, 123456790, None), + ], + ) + self.enterContext(patch("remindly.admin.load_dotenv")) + self.enterContext( + patch.dict( + os.environ, + { + "DATABASE_PATH": str(directory / "reports.db"), + "ADMIN_TELEGRAM_IDS": "123, 456", + "ADMIN_DATABASE_PATH": str(directory / "access.db"), + "ADMIN_TELEGRAM_CLIENT_ID": "987", + "ADMIN_TELEGRAM_CLIENT_SECRET": "test-secret", + "ADMIN_TELEGRAM_REDIRECT_URI": f"{ORIGIN}/auth/telegram", + "ADMIN_SESSION_SECRET": "s" * 40, + }, + clear=True, + ) + ) + self.app = create_app() + self.app.testing = True + self.client = self.app.test_client() + + def test_authenticated_list_filters_detail_and_revocation(self) -> None: + self.assertEqual(302, self.client.get("/reports/draft_ai", base_url=ORIGIN).status_code) + self.assertEqual(200, self.client.get("/login", base_url=ORIGIN).status_code) + with self.client.session_transaction(base_url=ORIGIN) as session: + session["telegram_user_id"] = 123 + listing = self.client.get( + "/?reason=time&parser_source=ai&model=test-model&bot_version=1.0", base_url=ORIGIN + ) + self.assertEqual(200, listing.status_code) + self.assertIn(b"/reports/draft_ai", listing.data) + self.assertNotIn(b"/reports/draft_rules", listing.data) + self.assertNotIn(b"alert(1)", listing.data) + self.assertNotIn(b"reporter_demo", listing.data) + detail = self.client.get("/reports/draft_ai?reason=time&model=test-model", base_url=ORIGIN) + self.assertIn(b'href="/?reason=time&model=test-model"', detail.data) + self.assertIn(b"<script>alert(1)</script>", detail.data) + self.assertIn(b"123456789", detail.data) + self.assertIn(b"@reporter_demo", detail.data) + legacy = self.client.get("/reports/draft_rules", base_url=ORIGIN) + self.assertIn("這筆回報未記錄回報者。", legacy.get_data(as_text=True)) + id_only = self.client.get("/reports/draft_id_only", base_url=ORIGIN) + self.assertIn("未設定", id_only.get_data(as_text=True)) + self.assertEqual("no-store", detail.headers["Cache-Control"]) + self.assertEqual(404, self.client.get("/reports/missing", base_url=ORIGIN).status_code) + self.app.config["ADMIN_TELEGRAM_IDS"] = frozenset({456}) + self.assertEqual(403, self.client.get("/reports/draft_ai", base_url=ORIGIN).status_code) + self.assertEqual(302, self.client.get("/", base_url=ORIGIN).status_code) + + def test_manage_maintainers_and_revoke_active_session(self) -> None: + with self.client.session_transaction(base_url=ORIGIN) as session: + session["telegram_user_id"] = 123 + session["logout_token"] = "csrf" + self.assertIn("人員權限", self.client.get("/maintainers", base_url=ORIGIN).text) + self.assertEqual( + 403, + self.client.post("/maintainers", data={"user_id": "789"}, base_url=ORIGIN).status_code, + ) + for value in ("", "@someone", "0", "-1", "1.5", "123", str(2**63), "123"): + self.assertEqual( + 400, + self.client.post( + "/maintainers", data={"user_id": value, "token": "csrf"}, base_url=ORIGIN + ).status_code, + ) + for _ in range(2): + self.assertEqual( + 303, + self.client.post( + "/maintainers", data={"user_id": "789", "token": "csrf"}, base_url=ORIGIN + ).status_code, + ) + restarted_app = create_app() + restarted_app.testing = True + viewer = restarted_app.test_client() + with viewer.session_transaction(base_url=ORIGIN) as session: + session["telegram_user_id"] = 789 + session["logout_token"] = "viewer-csrf" + self.assertEqual(200, viewer.get("/", base_url=ORIGIN).status_code) + self.assertNotIn('href="/maintainers"', viewer.get("/", base_url=ORIGIN).text) + self.assertEqual(403, viewer.get("/maintainers", base_url=ORIGIN).status_code) + self.assertEqual( + 403, + viewer.post( + "/maintainers", data={"user_id": "999", "token": "viewer-csrf"}, base_url=ORIGIN + ).status_code, + ) + self.assertEqual( + 403, + viewer.post( + "/maintainers/789/remove", data={"token": "viewer-csrf"}, base_url=ORIGIN + ).status_code, + ) + self.assertEqual( + 403, self.client.post("/maintainers/789/remove", base_url=ORIGIN).status_code + ) + self.assertEqual( + 400, + self.client.post( + "/maintainers/123/remove", data={"token": "csrf"}, base_url=ORIGIN + ).status_code, + ) + self.assertEqual( + 303, + self.client.post( + "/maintainers/789/remove", data={"token": "csrf"}, base_url=ORIGIN + ).status_code, + ) + self.assertEqual(403, viewer.get("/reports/draft_ai", base_url=ORIGIN).status_code) + self.assertEqual(302, viewer.get("/", base_url=ORIGIN).status_code) + self.assertEqual(200, self.client.get("/", base_url=ORIGIN).status_code) + + def test_member_notes_profiles_and_existing_database_upgrade(self) -> None: + access_path = os.environ["ADMIN_DATABASE_PATH"] + with sqlite3.connect(access_path) as connection: + connection.execute("drop table maintainers") + connection.execute( + "create table maintainers (user_id integer primary key, " + "added_by integer not null, added_at text not null default CURRENT_TIMESTAMP)" + ) + connection.execute("insert into maintainers (user_id, added_by) values (789, 123)") + upgraded = create_app() + upgraded.testing = True + client = upgraded.test_client() + with client.session_transaction(base_url=ORIGIN) as session: + session["telegram_user_id"] = 123 + session["logout_token"] = "csrf" + self.assertIn("未命名帳號", client.get("/maintainers", base_url=ORIGIN).text) + note = "" + self.assertEqual( + 403, + client.post("/maintainers/789/note", data={"note": note}, base_url=ORIGIN).status_code, + ) + self.assertEqual( + 400, + client.post( + "/maintainers/789/note", data={"note": "x" * 81, "token": "csrf"}, base_url=ORIGIN + ).status_code, + ) + self.assertEqual( + 303, + client.post( + "/maintainers/789/note", data={"note": note, "token": "csrf"}, base_url=ORIGIN + ).status_code, + ) + page = client.get("/maintainers", base_url=ORIGIN).text + self.assertIn("<script>小橘</script>", page) + self.assertNotIn(note, page) + self.assertNotIn("尚未登入", page) + self.assertNotIn("曾登入", page) + self.assertIn('class="identity-pill">ID 789', page) + with sqlite3.connect(access_path) as connection: + connection.execute( + "insert into user_profiles (user_id, display_name, username) " + "values (789, 'Telegram 名稱', 'orange')" + ) + self.assertIn("Telegram 名稱", client.get("/maintainers", base_url=ORIGIN).text) + self.assertIn("@orange", client.get("/maintainers", base_url=ORIGIN).text) + self.assertEqual( + 303, + client.post( + "/maintainers", + data={"user_id": "890", "note": "共同維護者", "token": "csrf"}, + base_url=ORIGIN, + ).status_code, + ) + self.assertIn("共同維護者", client.get("/maintainers", base_url=ORIGIN).text) + with client.session_transaction(base_url=ORIGIN) as session: + session["telegram_user_id"] = 789 + self.assertEqual( + 403, + client.post( + "/maintainers/890/note", data={"note": "越權", "token": "csrf"}, base_url=ORIGIN + ).status_code, + ) + + def test_oidc_validates_signed_claims_and_applies_allowlist(self) -> None: + key = RSAKey.generate_key(parameters={"kid": "test"}) + for user_id, audience, wrong_nonce, expected, name, username in ( + (123, "987", False, 302, "小橘", "orange"), + (123, "987", False, 302, "小橘改名", None), + (999, "987", False, 403, "未授權", "denied"), + (123, "wrong-audience", False, 400, "不可覆寫", "bad"), + (123, "987", True, 400, "不可覆寫", "bad"), + ): + with self.subTest(user_id=user_id, audience=audience, wrong_nonce=wrong_nonce): + start = self.client.get("/login/telegram", base_url=ORIGIN) + params = parse_qs(urlsplit(start.location).query) + self.assertEqual(["S256"], params["code_challenge_method"]) + self.assertEqual(["openid profile"], params["scope"]) + now = int(time.time()) + id_token = jwt.encode( + {"alg": "RS256", "kid": "test"}, + { + "iss": "https://oauth.telegram.org", + "sub": "opaque-oidc-subject", + "aud": audience, + "iat": now, + "exp": now + 300, + "nonce": "wrong" if wrong_nonce else params["nonce"][0], + "id": user_id, + "name": name, + "preferred_username": username, + }, + key, + ) + with patch("authlib.integrations.requests_client.OAuth2Session.send") as send: + + def fake_telegram(request, *, signed_token=id_token, **_kwargs): + response = Response() + response.status_code = 200 + response.url = request.url + if request.url.endswith("/token"): + self.assertIn("code_verifier=", request.body) + data = { + "id_token": signed_token, + "access_token": "simulated", + "token_type": "Bearer", + } + else: + self.assertTrue(request.url.endswith("/.well-known/jwks.json")) + data = {"keys": [key.as_dict()]} + response._content = json.dumps(data).encode() + return response + + send.side_effect = fake_telegram + callback = self.client.get( + "/auth/telegram", + query_string={"code": "simulated", "state": params["state"][0]}, + base_url=ORIGIN, + ) + self.assertEqual(expected, callback.status_code) + with self.client.session_transaction(base_url=ORIGIN) as session: + self.assertEqual( + 123 if expected == 302 else None, session.get("telegram_user_id") + ) + self.assertNotIn("id_token", session) + with sqlite3.connect(os.environ["ADMIN_DATABASE_PATH"]) as connection: + profile = connection.execute( + "select display_name, username from user_profiles where user_id = ?", + (user_id,), + ).fetchone() + if expected == 302: + self.assertEqual((name, username), profile) + page = self.client.get("/maintainers", base_url=ORIGIN).text + self.assertIn(name, page) + if username: + self.assertIn("@" + username, page) + else: + self.assertNotIn("@orange", page) + elif user_id == 999: + self.assertIsNone(profile) + else: + self.assertEqual(("小橘改名", None), profile) + + self.assertEqual( + 400, + self.client.get("/auth/telegram?code=simulated&state=bad", base_url=ORIGIN).status_code, + ) diff --git a/tests/test_bot_router.py b/tests/test_bot_router.py index 242a471..aa43602 100644 --- a/tests/test_bot_router.py +++ b/tests/test_bot_router.py @@ -13,6 +13,7 @@ from remindly.reminders.parser import ReminderParser from remindly.reminders.renderer import ReminderRenderer, delivery_snooze_keyboard from remindly.reminders.service import ReminderService +from remindly.storage.reports import ReportStore from remindly.storage.session_stores import SqliteDraftStore, SqliteEditSessionStore from remindly.storage.sqlite import ReminderRepository from remindly.telegram.client import TelegramApiError @@ -96,6 +97,95 @@ def get_chat_member(self, chat_id: int, user_id: int) -> str: class BotRouterTest(unittest.TestCase): + def test_report_flow_requires_owner_and_submit_and_preserves_reminder(self) -> None: + with tempfile.TemporaryDirectory() as directory: + client = FakeTelegramClient() + repository = ReminderRepository(Path(directory) / "test.db") + router = build_router(client, repository, reports=True) + + send_text(router, 1, "@ReminderBot 明天下午三點提醒我拿包裹", chat=GROUP_CHAT) + card = client.messages[-1] + self.assertIn("使用 內建規則 解析 · None: + with tempfile.TemporaryDirectory() as directory: + client = FakeTelegramClient() + repository = ReminderRepository(Path(directory) / "test.db") + router = build_router(client, repository, reports=True) + + send_text(router, 1, "@ReminderBot 明天下午三點提醒我拿包裹", chat=GROUP_CHAT) + card = client.messages[-1] + start = "/start " + card.text.split("?start=", 1)[1].split('"', 1)[0] + send_text(router, 2, start) + click_button(router, 3, client.messages[-1], "時間錯了") + stale_preview = client.messages[-1] + send_text(router, 4, "改成下午四點", chat=GROUP_CHAT) + revised_card = client.messages[-1] + self.assertIn("16:00", revised_card.text) + click_button(router, 5, stale_preview, "送出回報") + self.assertIn("這張確認卡已失效", client.callback_answers[-1]) + + start = "/start " + revised_card.text.split("?start=", 1)[1].split('"', 1)[0] + send_text(router, 6, start) + click_button(router, 7, client.messages[-1], "時間錯了") + no_username = TelegramUser(USER.id, USER.first_name) + click_button(router, 8, client.messages[-1], "附上原句與結果", user=no_username) + self.assertIn("Telegram @username:未設定", client.messages[-1].text) + click_button(router, 9, client.messages[-1], "送出回報", user=no_username) + with repository.connect() as connection: + rows = connection.execute( + "select attachment_json, reporter_user_id, reporter_username " + "from reminder_parse_reports" + ).fetchall() + self.assertEqual(1, len(rows)) + self.assertIn("16:00", rows[0]["attachment_json"]) + self.assertIn("拿包裹", rows[0]["attachment_json"]) + self.assertIn('"parsed_at"', rows[0]["attachment_json"]) + self.assertEqual(USER.id, rows[0]["reporter_user_id"]) + self.assertIsNone(rows[0]["reporter_username"]) + def test_group_plain_text_reminder_request_is_ignored(self) -> None: with tempfile.TemporaryDirectory() as directory: client = FakeTelegramClient() @@ -503,6 +593,8 @@ def test_create_list_edit_and_delete_flow(self) -> None: def build_router( client: FakeTelegramClient, repository: ReminderRepository, + *, + reports: bool = False, ) -> BotRouter: repository.migrate() renderer = ReminderRenderer() @@ -519,6 +611,7 @@ def build_router( renderer=renderer, bot_username="ReminderBot", default_timezone="Asia/Taipei", + report_store=ReportStore(repository) if reports else None, ) @@ -528,11 +621,12 @@ def send_text( text: str, *, chat: TelegramChat = CHAT, + user: TelegramUser = USER, ) -> None: message = TelegramMessage( id=update_id, chat=chat, - from_user=USER, + from_user=user, text=text, entities=(), ) @@ -544,6 +638,8 @@ def click_button( update_id: int, message: SentMessage, label_contains: str, + *, + user: TelegramUser = USER, ) -> None: callback_chat = GROUP_CHAT if message.chat_id == GROUP_CHAT.id else CHAT callback_message = TelegramMessage( @@ -555,7 +651,7 @@ def click_button( ) callback = TelegramCallbackQuery( id=f"cb_{update_id}", - from_user=USER, + from_user=user, message=callback_message, data=find_callback_data(message.reply_markup, label_contains), ) @@ -566,9 +662,7 @@ def button_labels(reply_markup: dict[str, Any] | None) -> list[str]: if not reply_markup: return [] return [ - str(button["text"]) - for row in reply_markup.get("inline_keyboard", []) - for button in row + str(button["text"]) for row in reply_markup.get("inline_keyboard", []) for button in row ] diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 1c98b29..5db25e4 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -30,13 +30,30 @@ def test_migrate_records_latest_version_and_is_idempotent(self) -> None: tables = table_names(connection) self.assertEqual(expected_migration_rows(), rows) - self.assertEqual(10, latest_schema_version()) + self.assertEqual(12, latest_schema_version()) self.assertIn("reminders", tables) self.assertIn("reminder_drafts", tables) self.assertIn("edit_sessions", tables) self.assertIn("chat_settings", tables) self.assertIn("ai_keys", tables) self.assertIn("ai_settings", tables) + self.assertIn("reminder_parse_reports", tables) + + def test_reporter_migration_keeps_existing_reports_without_an_identity(self) -> None: + with connect(Path(":memory:")) as connection: + ensure_migration_table(connection) + for migration in MIGRATIONS[:-1]: + apply_migration(connection, migration) + connection.execute( + """insert into reminder_parse_reports + (draft_id, reported_at, reason, parser_source, bot_version) + values ('draft_legacy', '2026-11-10T10:00:00+00:00', 'time', 'rules', '0.6.0')""" + ) + migrate_sqlite_database(connection) + row = connection.execute( + "select draft_id, reporter_user_id, reporter_username from reminder_parse_reports" + ).fetchone() + self.assertEqual(("draft_legacy", None, None), tuple(row)) def test_group_migration_ledger_failure_rolls_back_table_and_trigger(self): with connect(Path(":memory:")) as connection: @@ -139,6 +156,8 @@ def expected_migration_rows() -> list[tuple[int, str]]: (8, "ai_edit_context"), (9, "ai_key_last_error"), (10, "group_ai_keys"), + (11, "reminder_parse_reports"), + (12, "parse_report_reporter"), ] diff --git a/uv.lock b/uv.lock index 0be3f50..6f4a310 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,37 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "authlib" +version = "1.9.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/96/6e7ed72bb49762a9b65d51c4cb235d793f0485cd8d6f7e9404ce955a4808/authlib-1.9.5.tar.gz", hash = "sha256:3f031d5e9b6486089e91e8f7f1b68f36275f3151c66460d650fc90d420545fd7", size = 183564, upload-time = "2026-11-05T16:13:18.409Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/d7/d384e7a639dd1d018767ba0fe39601b61cded066bb7af38db31ec998252d/authlib-1.9.5-py2.py3-none-any.whl", hash = "sha256:e32a63e3d869196b61816b2602231862179a2ffc29358c009d1f022c8b86a8b2", size = 267074, upload-time = "2026-11-05T16:13:16.766Z" }, +] + +[[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.10.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, +] + [[package]] name = "cffi" version = "2.1.1" @@ -87,6 +118,146 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "click" +version = "8.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/37/edbf1b0707709013c0cf2e8317e03d97ed29bf4cac709fe328904ae92a38/click-8.5.1.tar.gz", hash = "sha256:9bbfada76703fd1d784b83eea012e3e8c6a001a876543ef104fa56aaaeb072b4", size = 399151, upload-time = "2026-11-06T17:37:01.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/7d/164b7ffc8624fe9efb4221bf9af485d5c1e41b9ae118045d2b6dc5457d8b/click-8.5.1-py3-none-any.whl", hash = "sha256:8bb5376d2006950386f8c6427613334a2acadc5f559cb80e638d00b4df957e37", size = 126163, upload-time = "2026-11-06T17:36:59.287Z" }, +] + [[package]] name = "cryptography" version = "46.0.7" @@ -140,6 +311,187 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] +[[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 = "gunicorn" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/8a/e4ef6ee11701b6cd64702848415ffb69eeff85cb388a3c6c7fe86f22f3f8/gunicorn-26.2.0.tar.gz", hash = "sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447", size = 787921, upload-time = "2026-08-24T15:05:59.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl", hash = "sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3", size = 228389, upload-time = "2026-08-24T15:05:57.67Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joserfc" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/31/5c3f27e82c92a7bbe1f028033ff19e0251540395c9be244c01a50de42bdb/joserfc-1.8.0.tar.gz", hash = "sha256:cbf03cb2c6db8cc5289f1ef8718842e53dba244b4c0de5fefa0c0eaee4bde8db", size = 241023, upload-time = "2026-11-03T10:45:57.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/7e/7ffaae13415e8f51c8e8d5d1845f2de7a27ebf2717e97a38d7e7446d6030/joserfc-1.8.0-py3-none-any.whl", hash = "sha256:176b836ac7ed9a46a88629e24076314b475f33a75d0bad7f26146e572eed7c79", size = 73147, upload-time = "2026-11-03T10:45:56.531Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + [[package]] name = "pycparser" version = "3.11" @@ -154,7 +506,12 @@ name = "remindly" version = "0.5.0" source = { editable = "." } dependencies = [ + { name = "authlib" }, { name = "cryptography" }, + { name = "flask" }, + { name = "gunicorn" }, + { name = "joserfc" }, + { name = "requests" }, ] [package.dev-dependencies] @@ -163,11 +520,33 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "cryptography", specifier = ">=46,<47" }] +requires-dist = [ + { name = "authlib", specifier = ">=1.7,<2" }, + { name = "cryptography", specifier = ">=46,<47" }, + { name = "flask", specifier = ">=3.1,<4" }, + { name = "gunicorn", specifier = ">=23,<27" }, + { name = "joserfc", specifier = ">=1.6,<2" }, + { name = "requests", specifier = ">=2.32.4,<3" }, +] [package.metadata.requires-dev] dev = [{ name = "ruff", specifier = ">=0.15.10" }] +[[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 = "ruff" version = "0.15.16" @@ -192,3 +571,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, +]