Compare commits

...

119 commits

Author SHA1 Message Date
f2b38a9f11 Merge pull request 'Update dependency flake8-bugbear to >=25.11.29' (#310) from renovate/flake8-bugbear-25.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 40s
Tests / test (push) Successful in 26s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #310
2025-12-02 07:29:19 +00:00
cdc5947a63 Merge pull request 'Update https://github.com/renovatebot/github-action action to v44.0.5' (#312) from renovate/https-github.com-renovatebot-github-action-44.x into main 2025-12-02 07:29:10 +00:00
5ef1fcc28c Merge pull request 'Lock file maintenance' (#311) from renovate/lock-file-maintenance into main
Some checks failed
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Build and Deploy Staging / deploy (push) Has been cancelled
Reviewed-on: #311
2025-12-02 07:29:06 +00:00
Renovate Bot
0aebbc42f0 Update https://github.com/renovatebot/github-action action to v44.0.5 2025-12-02 03:01:04 +00:00
Renovate Bot
0a47eedc9e Lock file maintenance
All checks were successful
Tests / test (push) Successful in 28s
2025-12-01 03:01:20 +00:00
Renovate Bot
c60a69a305 Update dependency flake8-bugbear to >=25.11.29
All checks were successful
Tests / test (push) Successful in 24s
2025-11-30 03:01:16 +00:00
f9bf71a2c1 Merge pull request 'Lock file maintenance' (#305) from renovate/lock-file-maintenance into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 40s
Tests / test (push) Successful in 25s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #305
2025-11-25 08:43:14 +00:00
1cf77e3b25 Merge pull request 'Update https://github.com/renovatebot/github-action action to v44.0.4' (#306) from renovate/https-github.com-renovatebot-github-action-44.x into main 2025-11-25 08:42:51 +00:00
230b66ef92 Merge pull request 'Update dependency sentry-sdk to >=2.46.0' (#307) from renovate/sentry-sdk-2.x into main
Some checks failed
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Build and Deploy Staging / deploy (push) Has been cancelled
Reviewed-on: #307
2025-11-25 08:42:47 +00:00
Renovate Bot
0c09ae8eca Update dependency sentry-sdk to >=2.46.0
All checks were successful
Tests / test (push) Successful in 26s
2025-11-25 03:00:52 +00:00
Renovate Bot
8e725932c4 Update https://github.com/renovatebot/github-action action to v44.0.4 2025-11-25 03:00:34 +00:00
Renovate Bot
870acfa81b Lock file maintenance
All checks were successful
Tests / test (push) Successful in 27s
2025-11-24 03:00:48 +00:00
0a454964f0 Merge pull request 'Update actions/checkout action to v6' (#304) from renovate/actions-checkout-6.x into main 2025-11-21 08:48:45 +00:00
31692a333f Merge pull request 'Update dependency django-allauth to >=65.13.1' (#303) from renovate/django-allauth-65.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 40s
Tests / test (push) Successful in 25s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #303
2025-11-21 08:48:40 +00:00
Renovate Bot
ea313eca1d Update actions/checkout action to v6 2025-11-21 03:01:10 +00:00
Renovate Bot
1ca02bfb4d Update dependency django-allauth to >=65.13.1
All checks were successful
Tests / test (push) Successful in 24s
2025-11-21 03:01:01 +00:00
8b0c2a8d43 Merge pull request 'Disk size SI units' (#302) from 287-disk-size-unit into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 44s
Tests / test (push) Successful in 25s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #302
2025-11-20 14:59:18 +00:00
0a621f6372 Add default unit for disk size field
All checks were successful
Tests / test (push) Successful in 29s
2025-11-20 12:06:54 +01:00
de6794046d Implement unit fields for numeric fields 2025-11-20 12:06:17 +01:00
a268625d80 Add number addon widget 2025-11-20 11:56:10 +01:00
c0d3a83c9d Merge pull request 'Update dependency coverage to >=7.12.0' (#300) from renovate/coverage-7.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 40s
Tests / test (push) Successful in 27s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #300
2025-11-20 08:18:29 +00:00
f72c93ff2c Merge pull request 'Update https://github.com/renovatebot/github-action action to v44.0.3' (#299) from renovate/https-github.com-renovatebot-github-action-44.x into main 2025-11-20 08:02:54 +00:00
bcca366381 Merge pull request 'Update dependency sentry-sdk to >=2.45.0' (#301) from renovate/sentry-sdk-2.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m3s
Tests / test (push) Successful in 26s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #301
2025-11-20 08:02:50 +00:00
Renovate Bot
aa40027f0a Update dependency sentry-sdk to >=2.45.0
All checks were successful
Tests / test (push) Successful in 29s
2025-11-19 03:01:01 +00:00
Renovate Bot
ae92516747 Update dependency coverage to >=7.12.0
All checks were successful
Tests / test (push) Successful in 29s
2025-11-19 03:00:55 +00:00
Renovate Bot
def01771c4 Update https://github.com/renovatebot/github-action action to v44.0.3 2025-11-18 03:00:49 +00:00
f99f8511bb
bump version 2025.11.13-0 -> 2025.11.17-0
All checks were successful
Build and Deploy Staging / build (push) Successful in 40s
Build and Deploy Antora Docs / build (push) Successful in 1m9s
Tests / test (push) Successful in 25s
Build and Deploy Staging / deploy (push) Successful in 6s
Build and Deploy Antora Docs / deploy (push) Successful in 5s
2025-11-17 11:50:15 +01:00
234a169128 Merge pull request 'Exoscale offboarding MVP' (#282) from 262-exoscale-offboarding into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 42s
Build and Deploy Production / build (release) Successful in 42s
Build and Deploy Production / deploy (release) Successful in 15s
Tests / test (push) Successful in 27s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #282
2025-11-17 10:49:23 +00:00
3e17e03da9
support hardcoded suspend plan for exoscale
All checks were successful
Tests / test (push) Successful in 27s
2025-11-17 11:48:19 +01:00
208f3c357d
odoo wants html for linebreaks 2025-11-17 11:38:02 +01:00
e4c64c4a17
Extract odoo helpdesk ticket creation
All checks were successful
Tests / test (push) Successful in 28s
2025-11-17 09:44:55 +01:00
61f1065bc6
Start work on implementing Exoscale offboarding
rel #262
2025-11-17 09:44:55 +01:00
3d62595663 Merge pull request 'Update dependency django-template-partials to >=25.3' (#297) from renovate/django-template-partials-25.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 39s
Tests / test (push) Successful in 26s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #297
2025-11-17 08:44:27 +00:00
10fe5a9b2a Merge pull request 'Lock file maintenance' (#298) from renovate/lock-file-maintenance into main
Some checks failed
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Build and Deploy Staging / deploy (push) Has been cancelled
Reviewed-on: #298
2025-11-17 08:44:14 +00:00
68335a098d Merge pull request 'Allow admins to disable the expert mode form' (#296) from 289-hide-expert-mode into main
Some checks failed
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Build and Deploy Staging / deploy (push) Has been cancelled
Reviewed-on: #296
2025-11-17 08:43:32 +00:00
dd2097a281 Merge pull request 'Support single (non-array) FQDN values' (#295) from 288-single-fqdn into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Build and Deploy Staging / build (push) Successful in 41s
Tests / test (push) Has been cancelled
Reviewed-on: #295
2025-11-17 08:42:36 +00:00
Renovate Bot
6996e56de7 Lock file maintenance
All checks were successful
Tests / test (push) Successful in 23s
2025-11-17 03:01:02 +00:00
Renovate Bot
e8d6c1acd5 Update dependency django-template-partials to >=25.3
All checks were successful
Tests / test (push) Successful in 24s
2025-11-15 03:01:04 +00:00
1dbeb31985 Allow admins to disable the expert mode form
All checks were successful
Tests / test (push) Successful in 27s
closes #289
2025-11-14 17:31:18 +01:00
3f9fdd0e48 Make sure fqdn generation works with single values
All checks were successful
Tests / test (push) Successful in 27s
2025-11-14 16:52:14 +01:00
4097261efe Support single-value FQDN in "open" button 2025-11-14 16:41:44 +01:00
63f5982350
improve release workflow with tags
All checks were successful
Build and Deploy Staging / build (push) Successful in 39s
Tests / test (push) Successful in 26s
Build and Deploy Staging / deploy (push) Successful in 6s
2025-11-13 14:11:46 +01:00
325d274cea
bump version 2025.10.27-0 -> 2025.11.13-0
All checks were successful
Build and Deploy Staging / build (push) Successful in 40s
Build and Deploy Antora Docs / build (push) Successful in 1m8s
Tests / test (push) Successful in 26s
Build and Deploy Staging / deploy (push) Successful in 7s
Build and Deploy Antora Docs / deploy (push) Successful in 4s
2025-11-13 11:05:43 +01:00
4ffbd0fe5d Merge pull request '"View Availability" is now "Get It"' (#285) from rename-view-availability into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 43s
Tests / test (push) Successful in 27s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #285
2025-11-13 10:00:20 +00:00
db590e0115
view availability is now get it
All checks were successful
Tests / test (push) Successful in 26s
which is a much better call to action
2025-11-13 10:59:32 +01:00
e70c09dcee Merge pull request 'Add "open" button to instances with FQDN' (#283) from 256-open-button into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 42s
Tests / test (push) Successful in 28s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #283
2025-11-13 09:57:54 +00:00
1f2524b4e8 Merge pull request 'Hide billing addresses' (#281) from 246-hide-billing-address into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 39s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #281
2025-11-13 09:52:58 +00:00
35a382db1b Merge pull request 'Update dependency pytest to >=9.0.1' (#284) from renovate/pytest-9.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 41s
Tests / test (push) Successful in 26s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #284
2025-11-13 08:30:40 +00:00
Renovate Bot
37875349a0 Update dependency pytest to >=9.0.1
All checks were successful
Tests / test (push) Successful in 27s
2025-11-13 03:01:13 +00:00
830ebfb890 Add "open" button to instances with FQDN
All checks were successful
Tests / test (push) Successful in 27s
closes #256
2025-11-12 12:16:52 +01:00
b3ecae6fd9 Add missing migration
All checks were successful
Tests / test (push) Successful in 26s
2025-11-12 10:34:55 +01:00
cbdbc253e8 Read-only billing address
ref #246
2025-11-12 10:34:42 +01:00
c00545b07c Merge pull request 'Update python Docker tag' (#272) from renovate/python-3.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m0s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #272
2025-11-12 09:13:05 +00:00
f714923d48 Merge pull request 'Update dependency django-fernet-encrypted-fields to >=0.3.1' (#278) from renovate/django-fernet-encrypted-fields-0.x into main
Some checks failed
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Build and Deploy Staging / deploy (push) Has been cancelled
Reviewed-on: #278
2025-11-12 09:12:51 +00:00
b23d216572 Merge pull request 'Update https://github.com/renovatebot/github-action action to v44.0.2' (#279) from renovate/https-github.com-renovatebot-github-action-44.x into main 2025-11-12 09:12:23 +00:00
2e2843e906 Merge pull request 'Update dependency sentry-sdk to >=2.44.0' (#280) from renovate/sentry-sdk-2.x into main
Some checks failed
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Build and Deploy Staging / deploy (push) Has been cancelled
Reviewed-on: #280
2025-11-12 09:12:18 +00:00
Renovate Bot
edc3bdfc06 Update dependency sentry-sdk to >=2.44.0
All checks were successful
Tests / test (push) Successful in 24s
2025-11-12 03:01:08 +00:00
Renovate Bot
111cf3f03a Update python Docker tag
All checks were successful
Tests / test (push) Successful in 23s
2025-11-11 03:02:31 +00:00
Renovate Bot
779d7f72a2 Update https://github.com/renovatebot/github-action action to v44.0.2 2025-11-11 03:02:25 +00:00
Renovate Bot
3cb26fde55 Update dependency django-fernet-encrypted-fields to >=0.3.1
All checks were successful
Tests / test (push) Successful in 27s
2025-11-11 03:02:20 +00:00
21102f30c7 Merge pull request 'Custom form configuration' (#268) from 165-form-configuration into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 42s
Tests / test (push) Successful in 26s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #268
2025-11-10 14:49:32 +00:00
ffed6139cd Do not save empty form_config entries
All checks were successful
Tests / test (push) Successful in 30s
2025-11-10 14:53:23 +01:00
45f17cabaa Update schema to make label and type optional
All checks were successful
Tests / test (push) Successful in 28s
2025-11-10 14:33:41 +01:00
f3e14b4c85 Fix tabs for custom fieldsets
All checks were successful
Tests / test (push) Successful in 30s
2025-11-10 14:15:22 +01:00
9ac9f5e1c9 Add print debugging to tab setup
All checks were successful
Tests / test (push) Successful in 29s
2025-11-10 14:04:42 +01:00
7a8dc91afe Make sure max length applies to textarea 2025-11-10 14:02:40 +01:00
c7c22aa265 Add default config 2025-11-10 14:02:34 +01:00
561abc5f76 Merge pull request 'Update dependency coverage to >=7.11.3' (#274) from renovate/coverage-7.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 39s
Tests / test (push) Successful in 26s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #274
2025-11-10 12:36:24 +00:00
93b5a2a366 Merge pull request 'Update dependency pytest to v9' (#276) from renovate/pytest-9.x into main
Some checks are pending
Build and Deploy Staging / build (push) Waiting to run
Build and Deploy Staging / deploy (push) Blocked by required conditions
Tests / test (push) Waiting to run
Reviewed-on: #276
2025-11-10 12:36:13 +00:00
228ab9bc0d Make fqdn generator fail silently 2025-11-10 13:36:09 +01:00
821e150239 Merge pull request 'Update dependency black to >=25.11.0' (#277) from renovate/black-25.x into main
Some checks failed
Tests / test (push) Waiting to run
Build and Deploy Staging / build (push) Has been cancelled
Build and Deploy Staging / deploy (push) Has been cancelled
Reviewed-on: #277
2025-11-10 12:35:42 +00:00
Renovate Bot
0806523f18 Update https://github.com/renovatebot/github-action action to v44 2025-11-10 03:01:51 +00:00
Renovate Bot
eb8176d446 Update dependency black to >=25.11.0
All checks were successful
Tests / test (push) Successful in 25s
2025-11-10 03:01:40 +00:00
Renovate Bot
14f813fe25 Update dependency coverage to >=7.11.3
All checks were successful
Tests / test (push) Successful in 23s
2025-11-10 03:01:35 +00:00
Renovate Bot
ec72ab88c4 Update dependency pytest to v9
All checks were successful
Tests / test (push) Successful in 26s
2025-11-09 03:01:47 +00:00
985e4b47c0 Make expert mode toggle less prominent
All checks were successful
Tests / test (push) Successful in 28s
2025-11-07 11:41:08 +01:00
6182b36daf Implicitly fix admin choices configuration 2025-11-07 11:36:28 +01:00
1ed261d4b2 Coerce string-numbers to numbers in admin form 2025-11-07 11:28:49 +01:00
14d60f80ca Merge pull request 'Skip offering selection if there is only one' (#273) from 258-skip-service-provider into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 38s
Tests / test (push) Successful in 26s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #273
2025-11-07 07:45:21 +00:00
fa7a170871 Skip offering selection if there is only one
All checks were successful
Tests / test (push) Successful in 26s
closes #258
2025-11-06 16:38:13 +01:00
ece60ad3b1 Implement default values in custom forms
All checks were successful
Tests / test (push) Successful in 28s
2025-11-06 16:14:02 +01:00
bab9d636ee Fix missing number validators on custom form widgets 2025-11-06 15:58:56 +01:00
089dbb663a Fix custom choices not being used 2025-11-06 15:55:27 +01:00
b3bb41b322 Fix tabs on custom instance forms 2025-11-06 15:40:16 +01:00
29edfd2d3a Merge pull request 'Update dependency django to v5.2.8' (#271) from renovate/django-5.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 40s
Tests / test (push) Successful in 27s
Build and Deploy Staging / deploy (push) Successful in 7s
Reviewed-on: #271
2025-11-06 14:26:23 +00:00
68dce4c5fb Make form_config integers optional 2025-11-06 15:07:14 +01:00
7fbd57d1b1 Add basic form generation tests 2025-11-06 14:53:58 +01:00
16d8ac0c6d Fix missing form when no custom form is present 2025-11-06 14:52:13 +01:00
9e1804a141 Split up crd.py into module 2025-11-06 14:32:16 +01:00
5b496ec5b2 Fix migration story 2025-11-06 14:22:50 +01:00
Renovate Bot
7d04e20bc0 Update dependency django to v5.2.8
All checks were successful
Tests / test (push) Successful in 25s
2025-11-06 03:01:31 +00:00
bbc1d735b6 Merge pull request 'Make it more clear how to register an account' (#270) from login-screen-two-buttons into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 47s
Tests / test (push) Successful in 24s
Build and Deploy Staging / deploy (push) Successful in 5s
Reviewed-on: #270
2025-11-05 16:01:35 +00:00
be00fb75cb
make it more clear how to register an account
All checks were successful
Tests / test (push) Successful in 25s
And use Sign in instead of Login which seems to be more common.
2025-11-05 17:00:28 +01:00
7aa1040f16 Merge pull request 'Update dependency django-allauth to >=65.13.0' (#265) from renovate/django-allauth-65.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 38s
Tests / test (push) Successful in 25s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #265
2025-11-05 14:42:12 +00:00
6ef18d415f Merge pull request 'Lock file maintenance' (#266) from renovate/lock-file-maintenance into main
Some checks failed
Build and Deploy Staging / deploy (push) Blocked by required conditions
Build and Deploy Staging / build (push) Successful in 56s
Tests / test (push) Has been cancelled
Reviewed-on: #266
2025-11-05 14:41:00 +00:00
948ff5b8d8 Merge pull request 'Update https://github.com/renovatebot/github-action action to v43.0.20' (#267) from renovate/https-github.com-renovatebot-github-action-43.x into main
Reviewed-on: #267
2025-11-05 14:40:46 +00:00
ca485978b9 Code style
All checks were successful
Tests / test (push) Successful in 28s
2025-11-05 10:45:37 +01:00
59e7a75c51 Simplify form configuration, remove generators reference 2025-11-05 10:42:41 +01:00
a5d46b696f Code style
All checks were successful
Tests / test (push) Successful in 26s
2025-11-05 10:37:11 +01:00
5cc582b638 Validate fields used in custom form config 2025-11-05 10:37:01 +01:00
aa77a10de2 Implement instance creation from custom form
All checks were successful
Tests / test (push) Successful in 28s
2025-11-05 10:19:56 +01:00
7f99c78084 Fix array widget container ID 2025-11-05 10:18:46 +01:00
2931315b96 Remove org field from generated form 2025-11-05 10:15:23 +01:00
63039171c1 Fix FQDN generation 2025-11-05 10:14:52 +01:00
652e0798f4 Make sure FQDN generation works with custom form 2025-11-05 10:12:34 +01:00
9e7330e24d Fix form display details 2025-11-05 10:11:56 +01:00
cedcab85c4 Use custom forms in instance update 2025-11-05 10:11:56 +01:00
0045e532ee Generate custom service form from config 2025-11-05 10:11:56 +01:00
357e39b543 Remove expert fields, add form config 2025-11-05 10:11:56 +01:00
1cf1947539 Provide form building schema 2025-11-05 10:11:56 +01:00
880d10c5e5 Remove Advanced Fields feature
we don't need it, as the new expert mode will be just the full current
form
2025-11-05 10:11:56 +01:00
Renovate Bot
9eb6d71212 Update https://github.com/renovatebot/github-action action to v43.0.20 2025-11-04 03:00:56 +00:00
Renovate Bot
078f5aa90f Lock file maintenance
All checks were successful
Tests / test (push) Successful in 25s
2025-11-03 03:01:08 +00:00
Renovate Bot
ac8d38eee1 Update dependency django-allauth to >=65.13.0
All checks were successful
Tests / test (push) Successful in 23s
2025-11-01 03:01:01 +00:00
Renovate Bot
575a4c7f8f Update https://github.com/renovatebot/github-action action to v43.0.19 2025-10-30 10:11:47 +00:00
Renovate Bot
82842f0c8e Update dependency node to v24 2025-10-30 10:11:42 +00:00
930fd21fba Merge pull request 'Update dependency sentry-sdk to >=2.43.0' (#261) from renovate/sentry-sdk-2.x into main
All checks were successful
Build and Deploy Staging / build (push) Successful in 1m0s
Tests / test (push) Successful in 30s
Build and Deploy Staging / deploy (push) Successful in 6s
Reviewed-on: #261
2025-10-30 10:11:39 +00:00
Renovate Bot
660ea8af20 Update dependency sentry-sdk to >=2.43.0
All checks were successful
Tests / test (push) Successful in 29s
2025-10-30 03:01:05 +00:00
96469c0212
add changelog tooling
All checks were successful
Build and Deploy Staging / build (push) Successful in 44s
Build and Deploy Antora Docs / build (push) Successful in 36s
Tests / test (push) Successful in 27s
Build and Deploy Staging / deploy (push) Successful in 6s
Build and Deploy Antora Docs / deploy (push) Successful in 4s
2025-10-27 15:37:58 +01:00
58 changed files with 4296 additions and 1255 deletions

View file

@ -12,6 +12,9 @@ on:
- "pyproject.toml"
- "uv.lock"
workflow_dispatch:
release:
types:
- published
jobs:
build:
@ -23,7 +26,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -69,7 +72,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Determine image tag
id: determine-tag

View file

@ -22,7 +22,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -53,7 +53,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.19

View file

@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@ -49,7 +49,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Deploy to OpenShift
uses: docker://quay.io/appuio/oc:v4.19

View file

@ -11,15 +11,15 @@ jobs:
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
node-version: "24"
- name: Renovate
uses: https://github.com/renovatebot/github-action@v43.0.18
uses: https://github.com/renovatebot/github-action@v44.0.5
with:
token: ${{ secrets.RENOVATE_TOKEN }}
env:

View file

@ -18,12 +18,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
node-version: "24"
- name: Install uv
uses: https://github.com/astral-sh/setup-uv@v7

View file

@ -1 +1 @@
3.13
3.14

View file

@ -1,4 +1,4 @@
FROM python:3.13-slim
FROM python:3.14-slim
EXPOSE 8000
WORKDIR /app

View file

@ -4,7 +4,7 @@
The Servala Self-Service Portal
Latest release: 2025.10.27-0
Latest release: 2025.11.17-0
## Documentation

View file

@ -5,6 +5,7 @@
** xref:web-portal-admin.adoc[Admin]
** xref:web-portal-controlplanes.adoc[Control-Planes]
** xref:web-portal-billingentity.adoc[Billing Entities]
** xref:web-portal-changelog.adoc[Changelog]
* xref:web-portal-planning.adoc[]
** xref:user-stories.adoc[]

View file

@ -0,0 +1,97 @@
= Portal Changelog
== 2025.11.17-0
=== API
* Exoscale offboarding MVP (link:https://servala.app.codey.ch/servala/servala-portal/pulls/282[#282])
=== UI/UX
* Allow admins to disable the expert mode form (link:https://servala.app.codey.ch/servala/servala-portal/pulls/296[#296])
* Support single (non-array) FQDN values (link:https://servala.app.codey.ch/servala/servala-portal/pulls/295[#295])
* "View Availability" is now "Get It" (link:https://servala.app.codey.ch/servala/servala-portal/pulls/285[#285])
* Add "open" button to instances with FQDN (link:https://servala.app.codey.ch/servala/servala-portal/pulls/283[#283])
* Hide billing addresses (link:https://servala.app.codey.ch/servala/servala-portal/pulls/281[#281])
* Custom form configuration (link:https://servala.app.codey.ch/servala/servala-portal/pulls/268[#268])
* Skip offering selection if there is only one (link:https://servala.app.codey.ch/servala/servala-portal/pulls/273[#273])
* Make it more clear how to register an account (link:https://servala.app.codey.ch/servala/servala-portal/pulls/270[#270])
=== dependencies
* Update dependency django-template-partials to >=25.3 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/297[#297])
* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/298[#298])
* Update dependency pytest to >=9.0.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/284[#284])
* Update Python to 3.14 tag (link:https://servala.app.codey.ch/servala/servala-portal/pulls/272[#272])
* Update dependency django-fernet-encrypted-fields to >=0.3.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/278[#278])
* Update https://github.com/renovatebot/github-action action to v44.0.2 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/279[#279])
* Update dependency sentry-sdk to >=2.44.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/280[#280])
* Update dependency coverage to >=7.11.3 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/274[#274])
* Update dependency pytest to v9 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/276[#276])
* Update dependency black to >=25.11.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/277[#277])
* Update https://github.com/renovatebot/github-action action to v44 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/275[#275])
* Update dependency django to v5.2.8 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/271[#271])
* Update dependency django-allauth to >=65.13.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/265[#265])
* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/266[#266])
* Update https://github.com/renovatebot/github-action action to v43.0.20 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/267[#267])
* Update https://github.com/renovatebot/github-action action to v43.0.19 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/259[#259])
* Update dependency node to v24 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/260[#260])
* Update dependency sentry-sdk to >=2.43.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/261[#261])
== 2025.11.13-0
=== UI/UX
* "View Availability" is now "Get It" (link:https://servala.app.codey.ch/servala/servala-portal/pulls/285[#285])
* Add "open" button to instances with FQDN (link:https://servala.app.codey.ch/servala/servala-portal/pulls/283[#283])
* Hide billing addresses (link:https://servala.app.codey.ch/servala/servala-portal/pulls/281[#281])
* Custom form configuration (link:https://servala.app.codey.ch/servala/servala-portal/pulls/268[#268])
* Skip offering selection if there is only one (link:https://servala.app.codey.ch/servala/servala-portal/pulls/273[#273])
* Make it more clear how to register an account (link:https://servala.app.codey.ch/servala/servala-portal/pulls/270[#270])
* Restrict user input to more sensible ranges (link:https://servala.app.codey.ch/servala/servala-portal/pulls/251[#251])
* Inline user info in service offering page (link:https://servala.app.codey.ch/servala/servala-portal/pulls/250[#250])
=== bug
* Fix generated FQDN not being submitted (link:https://servala.app.codey.ch/servala/servala-portal/pulls/249[#249])
=== dependencies
* Update dependency pytest to >=9.0.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/284[#284])
* Update Python to 3.14 tag (link:https://servala.app.codey.ch/servala/servala-portal/pulls/272[#272])
* Update dependency django-fernet-encrypted-fields to >=0.3.1 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/278[#278])
* Update https://github.com/renovatebot/github-action action to v44.0.2 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/279[#279])
* Update dependency sentry-sdk to >=2.44.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/280[#280])
* Update dependency coverage to >=7.11.3 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/274[#274])
* Update dependency pytest to v9 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/276[#276])
* Update dependency black to >=25.11.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/277[#277])
* Update https://github.com/renovatebot/github-action action to v44 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/275[#275])
* Update dependency django to v5.2.8 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/271[#271])
* Update dependency django-allauth to >=65.13.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/265[#265])
* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/266[#266])
* Update https://github.com/renovatebot/github-action action to v43.0.20 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/267[#267])
* Update https://github.com/renovatebot/github-action action to v43.0.19 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/259[#259])
* Update dependency node to v24 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/260[#260])
* Update dependency sentry-sdk to >=2.43.0 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/261[#261])
* Update dependency isort to v7 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/252[#252])
* Update dependency pillow to v12 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/253[#253])
* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/255[#255])
* Update https://github.com/astral-sh/setup-uv action to v7 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/254[#254])
* Update dependency flake8-bugbear to v25 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/248[#248])
* Update https://github.com/renovatebot/github-action action to v43.0.18 - autoclosed (link:https://servala.app.codey.ch/servala/servala-portal/pulls/239[#239])
* Update actions/setup-node action to v6 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/247[#247])
== 2025.10.27-0
=== UI/UX
* Restrict user input to more sensible ranges (link:https://servala.app.codey.ch/servala/servala-portal/pulls/251[#251])
* Inline user info in service offering page (link:https://servala.app.codey.ch/servala/servala-portal/pulls/250[#250])
=== bug
* Fix generated FQDN not being submitted (link:https://servala.app.codey.ch/servala/servala-portal/pulls/249[#249])
=== dependencies
* Update dependency isort to v7 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/252[#252])
* Update dependency pillow to v12 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/253[#253])
* Lock file maintenance (link:https://servala.app.codey.ch/servala/servala-portal/pulls/255[#255])
* Update https://github.com/astral-sh/setup-uv action to v7 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/254[#254])
* Update dependency flake8-bugbear to v25 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/248[#248])
* Update https://github.com/renovatebot/github-action action to v43.0.18 - autoclosed (link:https://servala.app.codey.ch/servala/servala-portal/pulls/239[#239])
* Update actions/setup-node action to v6 (link:https://servala.app.codey.ch/servala/servala-portal/pulls/247[#247])

116
hack/README.md Normal file
View file

@ -0,0 +1,116 @@
# Automation Scripts
This directory contains automation scripts for release management and changelog generation.
## Scripts
### bumpver-pre-commit-hook.sh
**Purpose**: Generates a changelog based on merged Pull Requests since the last release.
**What it does**:
1. Queries the Forgejo API for merged pull requests since the last release tag
2. Groups pull requests by their labels (first label if multiple labels are present)
3. Formats the changes in AsciiDoc format with third-level headers for each label group
4. Appends the changelog to `docs/modules/ROOT/pages/web-portal-changelog.adoc`
5. Adds the changelog file to git staging area
6. Saves the changelog content for the post-commit hook
**Note**: Pull requests without labels will be grouped under "Uncategorized".
**Requirements**:
- `FORGEJO_TOKEN` environment variable must be set with a valid Forgejo API token
- `jq` command-line JSON processor must be installed
- `curl` must be installed
### bumpver-post-commit-hook.sh
**Purpose**: Creates a release on Forgejo after a version bump.
**What it does**:
1. Gets the current version from `pyproject.toml`
2. Reads the changelog content generated by the pre-commit hook
3. Converts AsciiDoc format to Markdown (headers and links)
4. Creates or updates a release on Forgejo with the Markdown-formatted changelog
5. Cleans up temporary changelog files
**Note**: The script automatically converts AsciiDoc syntax to Markdown for Forgejo releases:
- `=== Header``### Header`
- `link:url[text]``[text](url)`
**Requirements**:
- `FORGEJO_TOKEN` environment variable must be set with a valid Forgejo API token
- `jq` command-line JSON processor must be installed
- `curl` must be installed
## Setup
### 1. Generate a Forgejo API Token
1. Log in to Forgejo at https://servala.app.codey.ch
2. Go to Settings → Applications → Generate New Token
3. Give it a descriptive name (e.g., "bumpver-automation")
4. Select the required permissions:
- `repo` (Full control of repositories)
5. Copy the generated token
### 2. Configure the token
Export the token as an environment variable:
```bash
export FORGEJO_TOKEN="your-token-here"
```
For permanent setup, add it to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.):
```bash
echo 'export FORGEJO_TOKEN="your-token-here"' >> ~/.bashrc
```
### 3. Update pyproject.toml
Update the bumpver configuration in `pyproject.toml` to use these hooks:
```toml
[tool.bumpver]
current_version = "2025.10.27-0"
version_pattern = "YYYY.0M.0D-INC0"
commit_message = "bump version {old_version} -> {new_version}"
tag_message = "{new_version}"
tag_scope = "default"
pre_commit_hook = "hack/bumpver-pre-commit-hook.sh"
post_commit_hook = "hack/bumpver-post-commit-hook.sh"
commit = true
tag = true
push = true
```
## Usage
Once configured, the hooks will run automatically when you bump the version:
```bash
# Or let bumpver determine the version based on the pattern
uvx bumpver update
```
The workflow is:
1. `bumpver` updates version files
2. **Pre-commit hook** runs: generates changelog, updates changelog file, stages changes
3. `bumpver` creates commit with version bump and changelog
4. `bumpver` creates git tag
5. **Post-commit hook** runs: creates Forgejo release
6. `bumpver` pushes commit and tags to remote
## Manual execution
You can also run the scripts manually:
```bash
# Generate changelog (run before committing)
./hack/pre-commit-hook.sh
# Create release (run after committing and tagging)
./hack/post-commit-hook.sh
```

149
hack/bumpver-post-commit-hook.sh Executable file
View file

@ -0,0 +1,149 @@
#!/bin/bash
set -euo pipefail
# Post-commit hook for bumpver to create a Forgejo release
# This script creates a release on Forgejo using the changelog generated in the pre-commit hook
# Configuration
FORGEJO_URL="https://servala.app.codey.ch"
REPO_OWNER="servala"
REPO_NAME="servala-portal"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check for required environment variable
if [ -z "${FORGEJO_TOKEN:-}" ]; then
echo -e "${RED}Error: FORGEJO_TOKEN environment variable is not set${NC}"
echo "Please set FORGEJO_TOKEN to your Forgejo API token"
exit 1
fi
# Get the current version from pyproject.toml
CURRENT_VERSION=$(grep -E 'current_version = ".*"' pyproject.toml | head -1 | sed -E 's/current_version = "(.*)"/\1/')
echo -e "${GREEN}Creating release for version: ${CURRENT_VERSION}${NC}"
# Get the latest tag (should match CURRENT_VERSION)
LATEST_TAG=$(git tag -l --sort=-v:refname | head -1)
if [ "$LATEST_TAG" != "$CURRENT_VERSION" ]; then
echo -e "${YELLOW}Warning: Latest tag (${LATEST_TAG}) doesn't match current version (${CURRENT_VERSION})${NC}"
echo -e "${YELLOW}Using current version: ${CURRENT_VERSION}${NC}"
fi
# Try to read the changelog generated by the pre-commit hook
CHANGELOG_DIR=".git/changelog"
CHANGELOG_FILE="${CHANGELOG_DIR}/${CURRENT_VERSION}.txt"
if [ -f "$CHANGELOG_FILE" ]; then
CHANGELOG_CONTENT=$(cat "$CHANGELOG_FILE")
echo -e "${GREEN}Found changelog content from pre-commit hook${NC}"
else
echo -e "${YELLOW}Warning: Changelog file not found at ${CHANGELOG_FILE}${NC}"
echo -e "${YELLOW}Generating changelog from tag information${NC}"
# Fallback: use git log to get commits since previous tag
PREVIOUS_TAG=$(git tag -l --sort=-v:refname | head -2 | tail -1)
if [ -n "$PREVIOUS_TAG" ]; then
CHANGELOG_CONTENT=$(git log --pretty=format:"* %s" "${PREVIOUS_TAG}..${LATEST_TAG}")
else
CHANGELOG_CONTENT="* Initial release"
fi
fi
# Convert AsciiDoc to Markdown
# 1. Convert === headers to ### (third-level headers)
# 2. Convert link:url[text] to [text](url)
CHANGELOG_MARKDOWN=$(echo "$CHANGELOG_CONTENT" | sed -E 's/^=== /### /g' | sed -E 's/link:([^[]+)\[([^]]+)\]/[\2](\1)/g')
# Create release body in Markdown format
RELEASE_BODY="## Release ${CURRENT_VERSION}
${CHANGELOG_MARKDOWN}
"
# Check if release already exists
API_URL="${FORGEJO_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${CURRENT_VERSION}"
EXISTING_RELEASE=$(curl -s -H "Authorization: token ${FORGEJO_TOKEN}" "${API_URL}")
# Check if we got a release back (not an error)
if echo "$EXISTING_RELEASE" | jq -e '.id' > /dev/null 2>&1; then
echo -e "${YELLOW}Release ${CURRENT_VERSION} already exists${NC}"
RELEASE_ID=$(echo "$EXISTING_RELEASE" | jq -r '.id')
echo -e "${GREEN}Updating existing release (ID: ${RELEASE_ID})${NC}"
# Update the existing release
UPDATE_URL="${FORGEJO_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/${RELEASE_ID}"
RESPONSE=$(curl -s -X PATCH \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "${CURRENT_VERSION}" \
--arg name "Release ${CURRENT_VERSION}" \
--arg body "${RELEASE_BODY}" \
'{
tag_name: $tag,
name: $name,
body: $body
}')" \
"${UPDATE_URL}")
if echo "$RESPONSE" | jq -e '.id' > /dev/null 2>&1; then
RELEASE_URL=$(echo "$RESPONSE" | jq -r '.html_url')
echo -e "${GREEN}Release updated successfully!${NC}"
echo -e "${GREEN}Release URL: ${RELEASE_URL}${NC}"
else
echo -e "${RED}Error updating release${NC}"
echo "$RESPONSE" | jq .
exit 1
fi
else
echo -e "${GREEN}Creating new release${NC}"
# Create new release
CREATE_URL="${FORGEJO_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases"
RESPONSE=$(curl -s -X POST \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "${CURRENT_VERSION}" \
--arg name "Release ${CURRENT_VERSION}" \
--arg body "${RELEASE_BODY}" \
'{
tag_name: $tag,
name: $name,
body: $body,
draft: false,
prerelease: false
}')" \
"${CREATE_URL}")
if echo "$RESPONSE" | jq -e '.id' > /dev/null 2>&1; then
RELEASE_URL=$(echo "$RESPONSE" | jq -r '.html_url')
echo -e "${GREEN}Release created successfully!${NC}"
echo -e "${GREEN}Release URL: ${RELEASE_URL}${NC}"
else
echo -e "${RED}Error creating release${NC}"
echo "$RESPONSE" | jq .
exit 1
fi
fi
# Clean up the changelog file
if [ -f "$CHANGELOG_FILE" ]; then
rm -f "$CHANGELOG_FILE"
fi
# Fetch the tag that Forgejo created when we made the release
echo -e "${GREEN}Fetching tags from remote to sync the tag created by Forgejo${NC}"
if git fetch --tags; then
echo -e "${GREEN}Tags synced successfully${NC}"
else
echo -e "${YELLOW}Warning: Failed to fetch tags from remote${NC}"
fi
exit 0

134
hack/bumpver-pre-commit-hook.sh Executable file
View file

@ -0,0 +1,134 @@
#!/bin/bash
set -euo pipefail
# Pre-commit hook for bumpver to generate changelog from Forgejo merge requests
# This script queries the Forgejo API for merged pull requests since the last release
# and appends them to the changelog file in AsciiDoc format
# Configuration
FORGEJO_URL="https://servala.app.codey.ch"
REPO_OWNER="servala"
REPO_NAME="servala-portal"
CHANGELOG_FILE="docs/modules/ROOT/pages/web-portal-changelog.adoc"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Check for required environment variable
if [ -z "${FORGEJO_TOKEN:-}" ]; then
echo -e "${RED}Error: FORGEJO_TOKEN environment variable is not set${NC}"
echo "Please set FORGEJO_TOKEN to your Forgejo API token"
exit 1
fi
# Get the new version from pyproject.toml (bumpver updates this before running the hook)
NEW_VERSION=$(grep -E 'current_version = ".*"' pyproject.toml | head -1 | sed -E 's/current_version = "(.*)"/\1/')
echo -e "${GREEN}Generating changelog for version: ${NEW_VERSION}${NC}"
# Get the previous tag
PREVIOUS_TAG=$(git tag -l --sort=-v:refname | head -2 | tail -1)
if [ -z "$PREVIOUS_TAG" ]; then
echo -e "${YELLOW}Warning: No previous tag found, using all merge requests${NC}"
# Get date of first commit
SINCE_DATE=$(git log --reverse --format=%aI | head -1)
else
echo -e "${GREEN}Previous version: ${PREVIOUS_TAG}${NC}"
# Get the date of the previous tag
SINCE_DATE=$(git log -1 --format=%aI "${PREVIOUS_TAG}")
fi
echo -e "${GREEN}Fetching merge requests since ${SINCE_DATE}${NC}"
# Query Forgejo API for closed/merged pull requests
# Forgejo API returns pull requests sorted by updated time
API_URL="${FORGEJO_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/pulls?state=closed&sort=updated&limit=100"
RESPONSE=$(curl -s -H "Authorization: token ${FORGEJO_TOKEN}" "${API_URL}")
# Check if curl was successful
if [ $? -ne 0 ]; then
echo -e "${RED}Error: Failed to fetch pull requests from Forgejo API${NC}"
exit 1
fi
# Filter merged PRs since the previous tag and group by labels
# Using jq to parse JSON, filter by merge date, and group by labels
CHANGELOG_ENTRIES=$(echo "${RESPONSE}" | jq -r --arg since "${SINCE_DATE}" '
# Filter merged PRs since the last tag
[.[] | select(.merged_at != null and .merged_at > $since)] |
sort_by(.merged_at) | reverse |
# Group by primary label (first label if multiple, or "Uncategorized")
group_by(
if (.labels | length) > 0 then
.labels[0].name
else
"Uncategorized"
end
) |
# Format each group
map(
# Group header
"=== " + (
if .[0].labels | length > 0 then
.[0].labels[0].name
else
"Uncategorized"
end
) + "\n" +
# List items in this group
(map("* " + .title + " (link:" + .html_url + "[#" + (.number | tostring) + "])") | join("\n"))
) |
join("\n\n")
')
if [ -z "$CHANGELOG_ENTRIES" ]; then
echo -e "${YELLOW}Warning: No merged pull requests found since ${PREVIOUS_TAG}${NC}"
CHANGELOG_ENTRIES="=== Uncategorized\n\n* No changes recorded"
fi
# Create changelog section
CHANGELOG_SECTION="
== ${NEW_VERSION}
${CHANGELOG_ENTRIES}
"
# Check if changelog file exists
if [ ! -f "$CHANGELOG_FILE" ]; then
echo -e "${RED}Error: Changelog file ${CHANGELOG_FILE} not found${NC}"
exit 1
fi
# Create temporary file with new changelog entry at the top
TMP_FILE=$(mktemp)
{
# Keep the title
head -1 "$CHANGELOG_FILE"
# Add the new changelog section
echo "$CHANGELOG_SECTION"
# Add the rest of the file (skip the title)
tail -n +2 "$CHANGELOG_FILE"
} > "$TMP_FILE"
# Replace the original file
mv "$TMP_FILE" "$CHANGELOG_FILE"
chmod 0644 "$CHANGELOG_FILE"
# Add the changelog file to git
git add "$CHANGELOG_FILE"
echo -e "${GREEN}Changelog updated successfully${NC}"
echo -e "${GREEN}Added ${CHANGELOG_FILE} to git staging area${NC}"
# Save changelog for post-commit hook
CHANGELOG_DIR=".git/changelog"
mkdir -p "$CHANGELOG_DIR"
echo "$CHANGELOG_ENTRIES" > "${CHANGELOG_DIR}/${NEW_VERSION}.txt"
exit 0

View file

@ -3,18 +3,18 @@ name = "servala"
version = "0.0.0"
description = "Servala portal server and frontend"
readme = "README.md"
requires-python = ">=3.13"
requires-python = ">=3.14.0"
dependencies = [
"argon2-cffi>=25.1.0",
"cryptography>=46.0.3",
"django==5.2.7",
"django-allauth>=65.12.1",
"django==5.2.8",
"django-allauth>=65.13.1",
"django-auditlog>=3.3.0",
"django-fernet-encrypted-fields>=0.3.0",
"django-fernet-encrypted-fields>=0.3.1",
"django-jsonform>=2.23.2",
"django-scopes>=2.0.0",
"django-storages[s3]>=1.14.6",
"django-template-partials>=25.2",
"django-template-partials>=25.3",
"jsonschema>=4.25.1",
"kubernetes>=34.1.0",
"pillow>=12.0.0",
@ -22,21 +22,21 @@ dependencies = [
"pyjwt>=2.10.1",
"requests>=2.32.5",
"rules>=3.5",
"sentry-sdk[django]>=2.42.1",
"sentry-sdk[django]>=2.46.0",
"urlman>=2.0.2",
]
[dependency-groups]
dev = [
"black>=25.9.0",
"black>=25.11.0",
"bumpver>=2025.1131",
"coverage>=7.11.0",
"coverage>=7.12.0",
"djlint>=1.36.4",
"flake8>=7.3.0",
"flake8-bugbear>=25.10.21",
"flake8-bugbear>=25.11.29",
"flake8-pyproject>=1.2.3",
"isort>=7.0.0",
"pytest>=8.4.2",
"pytest>=9.0.1",
"pytest-cov>=7.0.0",
"pytest-django>=4.11.1",
"pytest-mock>=3.15.1",
@ -61,15 +61,15 @@ testpaths = "src/tests"
pythonpath = "src"
[tool.bumpver]
current_version = "2025.10.27-0"
current_version = "2025.11.17-0"
version_pattern = "YYYY.0M.0D-INC0"
commit_message = "bump version {old_version} -> {new_version}"
tag_message = "{new_version}"
tag_scope = "default"
pre_commit_hook = ""
post_commit_hook = ""
pre_commit_hook = "hack/bumpver-pre-commit-hook.sh"
post_commit_hook = "hack/bumpver-post-commit-hook.sh"
commit = true
tag = true
tag = false
push = true
[tool.bumpver.file_patterns]

View file

@ -1 +1 @@
__version__ = "2025.10.27-0"
__version__ = "2025.11.17-0"

View file

@ -6,6 +6,7 @@ from django.contrib.auth.decorators import login_not_required
from django.core.mail import send_mail
from django.db import transaction
from django.http import JsonResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.csrf import csrf_exempt
@ -19,7 +20,8 @@ from servala.core.models import (
OrganizationRole,
User,
)
from servala.core.models.service import Service, ServiceOffering
from servala.core.models.service import Service, ServiceInstance, ServiceOffering
from servala.core.odoo import create_helpdesk_ticket
logger = logging.getLogger(__name__)
@ -28,9 +30,7 @@ logger = logging.getLogger(__name__)
@method_decorator(login_not_required, name="dispatch")
class OSBServiceInstanceView(OSBBasicAuthPermission, View):
"""
OSB API endpoint for service instance provisioning (onboarding).
Implements the PUT /v2/service_instances/:instance_id endpoint.
https://docs.servala.com/exoscale-osb.html#_onboarding
OSB API endpoint for service instance management via Exoscale.
"""
def _error(self, error):
@ -177,3 +177,169 @@ The Servala Team"""
recipient_list=[user.email],
fail_silently=False,
)
def delete(self, request, instance_id):
"""
This implements the Exoscale offboarding flow MVP.
https://docs.servala.com/exoscale-osb.html#_offboarding
"""
service_id = request.GET.get("service_id")
plan_id = request.GET.get("plan_id")
if not service_id:
return self._error("service_id is required but missing.")
if not plan_id:
return self._error("plan_id is required but missing.")
try:
service = Service.objects.get(osb_service_id=service_id)
service_offering = ServiceOffering.objects.get(
osb_plan_id=plan_id, service=service
)
except Service.DoesNotExist:
return self._error(f"Unknown service_id: {service_id}")
except ServiceOffering.DoesNotExist:
return self._error(
f"Unknown plan_id: {plan_id} for service_id: {service_id}"
)
self._create_action_helpdesk_ticket(
request=request,
action="Offboard",
instance_id=instance_id,
service=service,
service_offering=service_offering,
)
return JsonResponse({}, status=200)
def patch(self, request, instance_id):
"""
This implements the Exoscale suspension flow MVP.
https://docs.servala.com/exoscale-osb.html#_suspension
"""
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON in request body"}, status=400)
service_id = data.get("service_id")
plan_id = data.get("plan_id")
if not service_id:
return self._error("service_id is required but missing.")
if not plan_id:
return self._error("plan_id is required but missing.")
try:
service = Service.objects.get(osb_service_id=service_id)
# Special handling: when plan_id is "suspend", don't lookup service_offering
service_offering = None
if plan_id != "suspend":
service_offering = ServiceOffering.objects.get(
osb_plan_id=plan_id, service=service
)
except Service.DoesNotExist: # pragma: no-cover
return self._error(f"Unknown service_id: {service_id}")
except ServiceOffering.DoesNotExist: # pragma: no-cover
return self._error(
f"Unknown plan_id: {plan_id} for service_id: {service_id}"
)
self._create_action_helpdesk_ticket(
request=request,
action="Suspend",
instance_id=instance_id,
service=service,
service_offering=service_offering,
users=data.get("parameters", {}).get("users"),
)
return JsonResponse({}, status=200)
def _get_admin_url(self, model_name, pk):
admin_path = reverse(f"admin:{model_name}", args=[pk])
return self.request.build_absolute_uri(admin_path)
def _create_action_helpdesk_ticket(
self, request, action, instance_id, service, service_offering=None, users=None
):
"""
Create an Odoo helpdesk ticket for offboarding or suspension actions.
This is an MVP implementation that creates a ticket for manual handling.
"""
try:
service_instance = None
organization = None
try:
# Look for instances with this name in the service offering's context
filter_kwargs = {"name": instance_id}
if service_offering:
filter_kwargs["context__service_offering"] = service_offering
service_instance = (
ServiceInstance.objects.filter(**filter_kwargs)
.select_related("organization")
.first()
)
if service_instance:
organization = service_instance.organization
except Exception: # pragma: no cover
pass
description_parts = [f"Action: {action}", f"Service: {service.name}"]
if organization:
org_url = self._get_admin_url(
"core_organization_change", organization.pk
)
description_parts.append(
f"Organization: {organization.name} - {org_url}"
)
if service_instance:
instance_url = self._get_admin_url(
"core_serviceinstance_change", service_instance.pk
)
description_parts.append(
f"Instance: {service_instance.name} - {instance_url}"
)
else:
description_parts.append(f"Instance: {instance_id}")
if service_offering:
offering_url = self._get_admin_url(
"core_serviceoffering_change", service_offering.pk
)
description_parts.append(f"Service Offering: {offering_url}")
if users:
description_parts.append("<br/>Users:")
for user_data in users:
email = user_data.get("email", "N/A")
full_name = user_data.get("full_name", "N/A")
role = user_data.get("role", "N/A")
user_link = email
if email and email != "N/A":
try:
user = User.objects.get(email=email.strip().lower())
user_link = self._get_admin_url("core_user_change", user.pk)
except User.DoesNotExist:
pass
description_parts.append(f" - {full_name} ({user_link}) - {role}")
description = "<br/>".join(description_parts)
create_helpdesk_ticket(
title=f"Exoscale OSB {action} - {service.name} - {instance_id}",
description=description,
)
logger.info(
f"Created {action} helpdesk ticket for instance {instance_id}, service {service.name}"
)
except Exception as e:
logger.error(
f"Error creating Exoscale {action} helpdesk ticket for instance {instance_id}: {e}"
)

View file

@ -1,3 +1,6 @@
import json
from pathlib import Path
from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _
from django_jsonform.widgets import JSONFormWidget
@ -83,7 +86,13 @@ class BillingEntityAdmin(admin.ModelAdmin):
@admin.register(OrganizationOrigin)
class OrganizationOriginAdmin(admin.ModelAdmin):
list_display = ("name", "billing_entity", "default_odoo_sale_order_id")
list_display = (
"name",
"billing_entity",
"default_odoo_sale_order_id",
"hide_billing_address",
)
list_filter = ("hide_billing_address",)
search_fields = ("name",)
autocomplete_fields = ("billing_entity",)
filter_horizontal = ("limit_cloudproviders",)
@ -313,9 +322,9 @@ class ServiceDefinitionAdmin(admin.ModelAdmin):
(
_("Form Configuration"),
{
"fields": ("advanced_fields",),
"fields": ("form_config", "hide_expert_mode"),
"description": _(
"Configure which fields should be hidden behind an 'Advanced' toggle in the form"
"Optional custom form configuration. When provided, this will be used instead of auto-generating the form from the OpenAPI spec."
),
},
),
@ -323,19 +332,13 @@ class ServiceDefinitionAdmin(admin.ModelAdmin):
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
# JSON schema for advanced_fields field
advanced_fields_schema = {
"type": "array",
"title": "Advanced Fields",
"items": {
"type": "string",
"title": "Field Name",
"description": "Field name in dot notation (e.g., spec.parameters.monitoring.enabled)",
},
}
if "advanced_fields" in form.base_fields:
form.base_fields["advanced_fields"].widget = JSONFormWidget(
schema=advanced_fields_schema
schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json"
with open(schema_path) as f:
form_config_schema = json.load(f)
if "form_config" in form.base_fields:
form.base_fields["form_config"].widget = JSONFormWidget(
schema=form_config_schema
)
return form

View file

@ -1,557 +0,0 @@
import re
from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.forms.models import ModelForm, ModelFormMetaclass
from django.utils.translation import gettext_lazy as _
from servala.core.models import ServiceInstance
class CRDModel(models.Model):
"""Base class for all virtual CRD models"""
def __init__(self, **kwargs):
if spec := kwargs.pop("spec", None):
kwargs.update(unnest_data({"spec": spec}))
super().__init__(**kwargs)
class Meta:
abstract = True
def duplicate_field(field_name, model):
# Get the field from the model
field = model._meta.get_field(field_name)
# Create a new field with the same attributes
new_field = type(field).__new__(type(field))
new_field.__dict__.update(field.__dict__)
# Ensure the field is not linked to the original model
new_field.model = None
new_field.auto_created = False
return new_field
def generate_django_model(schema, group, version, kind):
"""
Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema.
"""
# We always need these three fields to know our own name and our full namespace
model_fields = {"__module__": "crd_models"}
for field_name in ("name", "organization", "context"):
model_fields[field_name] = duplicate_field(field_name, ServiceInstance)
# All other fields are generated from the schema, except for the
# resourceRef object
spec = schema["properties"].get("spec") or {}
spec["properties"].pop("resourceRef", None)
model_fields.update(build_object_fields(spec, "spec", parent_required=False))
# Store the original schema on the model class
model_fields["SCHEMA"] = schema
meta_class = type("Meta", (), {"app_label": "crd_models"})
model_fields["Meta"] = meta_class
# create the model class
model_name = kind
model_class = type(model_name, (CRDModel,), model_fields)
return model_class
def build_object_fields(schema, name, verbose_name_prefix=None, parent_required=False):
required_fields = schema.get("required") or []
properties = schema.get("properties") or {}
fields = {}
for field_name, field_schema in properties.items():
is_required = field_name in required_fields or parent_required
full_name = f"{name}.{field_name}"
result = get_django_field(
field_schema,
is_required,
field_name,
full_name,
verbose_name_prefix=verbose_name_prefix,
)
if isinstance(result, dict):
fields.update(result)
else:
fields[full_name] = result
return fields
def deslugify(title):
"""
Convert camelCase, PascalCase, or snake_case to human-readable title.
Handles known acronyms (e.g., postgreSQLParameters -> PostgreSQL Parameters).
"""
ACRONYMS = {
# Database systems
"SQL": "SQL",
"MYSQL": "MySQL",
"POSTGRESQL": "PostgreSQL",
"MARIADB": "MariaDB",
"MSSQL": "MSSQL",
"MONGODB": "MongoDB",
"REDIS": "Redis",
# Protocols
"HTTP": "HTTP",
"HTTPS": "HTTPS",
"FTP": "FTP",
"SFTP": "SFTP",
"SSH": "SSH",
"TLS": "TLS",
"SSL": "SSL",
# APIs
"API": "API",
"REST": "REST",
"GRPC": "gRPC",
"GRAPHQL": "GraphQL",
# Networking
"URL": "URL",
"URI": "URI",
"FQDN": "FQDN",
"DNS": "DNS",
"IP": "IP",
"TCP": "TCP",
"UDP": "UDP",
# Data formats
"JSON": "JSON",
"XML": "XML",
"YAML": "YAML",
"CSV": "CSV",
"HTML": "HTML",
"CSS": "CSS",
# Hardware
"CPU": "CPU",
"RAM": "RAM",
"GPU": "GPU",
"SSD": "SSD",
"HDD": "HDD",
# Identifiers
"ID": "ID",
"UUID": "UUID",
"GUID": "GUID",
"ARN": "ARN",
# Cloud providers
"AWS": "AWS",
"GCP": "GCP",
"AZURE": "Azure",
"IBM": "IBM",
# Kubernetes/Cloud
"DB": "DB",
"PVC": "PVC",
"PV": "PV",
"VPN": "VPN",
# Auth
"OS": "OS",
"LDAP": "LDAP",
"SAML": "SAML",
"OAUTH": "OAuth",
"JWT": "JWT",
# AWS Services
"S3": "S3",
"EC2": "EC2",
"RDS": "RDS",
"EBS": "EBS",
"IAM": "IAM",
}
if "_" in title:
# Handle snake_case
title = title.replace("_", " ")
words = title.split()
else:
# Handle camelCase/PascalCase with smart splitting
# This regex splits on:
# - Transition from lowercase to uppercase (camelCase)
# - Transition from multiple uppercase to an uppercase followed by lowercase (SQLParameters -> SQL Parameters)
words = re.findall(r"[A-Z]+(?=[A-Z][a-z]|\b)|[A-Z][a-z]+|[a-z]+|[0-9]+", title)
# Merge adjacent words if they form a known compound acronym (e.g., postgre + SQL = PostgreSQL)
merged_words = []
i = 0
while i < len(words):
if i < len(words) - 1:
# Check if current word + next word form a known acronym
combined = (words[i] + words[i + 1]).upper()
if combined in ACRONYMS:
merged_words.append(combined)
i += 2
continue
merged_words.append(words[i])
i += 1
# Capitalize each word, using proper casing for known acronyms
result = []
for word in merged_words:
word_upper = word.upper()
if word_upper in ACRONYMS:
result.append(ACRONYMS[word_upper])
else:
result.append(word.capitalize())
return " ".join(result)
def get_django_field(
field_schema, is_required, field_name, full_name, verbose_name_prefix=None
):
field_type = field_schema.get("type") or "string"
format = field_schema.get("format")
verbose_name_prefix = verbose_name_prefix or ""
verbose_name = f"{verbose_name_prefix} {deslugify(field_name)}".strip()
# Pass down the requirement status from parent to child fields
kwargs = {
"blank": not is_required, # All fields are optional by default
"null": not is_required,
"help_text": field_schema.get("description"),
"validators": [],
"verbose_name": verbose_name,
"default": field_schema.get("default"),
}
if minimum := field_schema.get("minimum"):
kwargs["validators"].append(MinValueValidator(minimum))
if maximum := field_schema.get("maximum"):
kwargs["validators"].append(MaxValueValidator(maximum))
if field_type == "string":
if format == "date-time":
return models.DateTimeField(**kwargs)
elif format == "date":
return models.DateField(**kwargs)
else:
max_length = field_schema.get("max_length") or 255
if pattern := field_schema.get("pattern"):
kwargs["validators"].append(RegexValidator(regex=pattern))
if choices := field_schema.get("enum"):
kwargs["choices"] = ((choice, choice) for choice in choices)
return models.CharField(max_length=max_length, **kwargs)
elif field_type == "integer":
return models.IntegerField(**kwargs)
elif field_type == "number":
return models.FloatField(**kwargs)
elif field_type == "boolean":
return models.BooleanField(**kwargs)
elif field_type == "object":
# Here we pass down the requirement status to nested objects
return build_object_fields(
field_schema,
full_name,
verbose_name_prefix=f"{verbose_name}:",
parent_required=is_required,
)
elif field_type == "array":
kwargs["help_text"] = field_schema.get("description") or _("List of values")
from servala.frontend.forms.widgets import DynamicArrayField
field = models.JSONField(**kwargs)
formfield_kwargs = {
"label": field.verbose_name,
"required": not field.blank,
}
array_validation = {}
if min_items := field_schema.get("min_items"):
array_validation["min_items"] = min_items
if max_items := field_schema.get("max_items"):
array_validation["max_items"] = max_items
if unique_items := field_schema.get("unique_items"):
array_validation["unique_items"] = unique_items
if items_schema := field_schema.get("items"):
array_validation["items_schema"] = items_schema
if array_validation:
formfield_kwargs["array_validation"] = array_validation
field.formfield = lambda: DynamicArrayField(**formfield_kwargs)
return field
return models.CharField(max_length=255, **kwargs)
def unnest_data(data):
result = {}
def _flatten_dict(d, parent_key=""):
for key, value in d.items():
new_key = f"{parent_key}.{key}" if parent_key else key
if isinstance(value, dict):
_flatten_dict(value, new_key)
else:
result[new_key] = value
_flatten_dict(data)
return result
class CrdModelFormMixin:
HIDDEN_FIELDS = [
"spec.compositeDeletePolicy",
"spec.compositionRef",
"spec.compositionRevisionRef",
"spec.compositionRevisionSelector",
"spec.compositionSelector",
"spec.compositionUpdatePolicy",
"spec.parameters.monitoring.alertmanagerConfigRef",
"spec.parameters.monitoring.alertmanagerConfigSecretRef",
"spec.parameters.network.serviceType",
"spec.parameters.scheduling",
"spec.parameters.security",
"spec.parameters.size.cpu",
"spec.parameters.size.memory",
"spec.parameters.size.requests.cpu",
"spec.parameters.size.requests.memory",
"spec.publishConnectionDetailsTo",
"spec.resourceRef",
"spec.writeConnectionSecretToRef",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.schema = self._meta.model.SCHEMA
for field in ("organization", "context"):
self.fields[field].widget = forms.HiddenInput()
for name, field in self.fields.items():
if name in self.HIDDEN_FIELDS or any(
name.startswith(f) for f in self.HIDDEN_FIELDS
):
field.widget = forms.HiddenInput()
field.required = False
# Mark advanced fields with a CSS class and data attribute
for name, field in self.fields.items():
if self.is_field_advanced(name):
field.widget.attrs.update(
{
"class": (
field.widget.attrs.get("class", "") + " advanced-field"
).strip(),
"data-advanced": "true",
}
)
if self.instance and self.instance.pk:
self.fields["name"].disabled = True
self.fields["name"].help_text = _("Name cannot be changed after creation.")
self.fields["name"].widget = forms.HiddenInput()
def strip_title(self, field_name, label):
field = self.fields[field_name]
if field and field.label and (position := field.label.find(label)) != -1:
field.label = field.label[position + len(label) :]
def has_mandatory_fields(self, field_list):
for field_name in field_list:
if field_name in self.fields and self.fields[field_name].required:
return True
return False
def is_field_advanced(self, field_name):
advanced_fields = getattr(self, "ADVANCED_FIELDS", [])
return field_name in advanced_fields or any(
field_name.startswith(f"{af}.") for af in advanced_fields
)
def are_all_fields_advanced(self, field_list):
if not field_list:
return False
return all(self.is_field_advanced(field_name) for field_name in field_list)
def get_fieldsets(self):
fieldsets = []
# General fieldset for non-spec fields
general_fields = [
field_name
for field_name in self.fields.keys()
if not field_name.startswith("spec.")
]
if general_fields:
fieldset = {
"title": "General",
"fields": general_fields,
"fieldsets": [],
"has_mandatory": self.has_mandatory_fields(general_fields),
"is_advanced": self.are_all_fields_advanced(general_fields),
}
if all(
[
isinstance(self.fields[field].widget, forms.HiddenInput)
for field in general_fields
]
):
fieldset["hidden"] = True
fieldsets.append(fieldset)
# Process spec fields
others = []
top_level_fieldsets = {}
hidden_spec_fields = []
for field_name in self.fields:
if field_name.startswith("spec."):
if isinstance(self.fields[field_name].widget, forms.HiddenInput):
hidden_spec_fields.append(field_name)
continue
parts = field_name.split(".")
if len(parts) == 2:
# Top-level spec field
others.append(field_name)
elif len(parts) == 3:
# Second-level field - promote to top-level fieldset
fieldset_key = f"{parts[1]}.{parts[2]}"
if not top_level_fieldsets.get(fieldset_key):
top_level_fieldsets[fieldset_key] = {
"fields": [],
"fieldsets": {},
"title": f"{deslugify(parts[2])}",
}
top_level_fieldsets[fieldset_key]["fields"].append(field_name)
else:
# Third-level and deeper - create nested fieldsets
fieldset_key = f"{parts[1]}.{parts[2]}"
if not top_level_fieldsets.get(fieldset_key):
top_level_fieldsets[fieldset_key] = {
"fields": [],
"fieldsets": {},
"title": f"{deslugify(parts[2])}",
}
sub_key = parts[3]
if not top_level_fieldsets[fieldset_key]["fieldsets"].get(sub_key):
top_level_fieldsets[fieldset_key]["fieldsets"][sub_key] = {
"title": deslugify(sub_key),
"fields": [],
}
top_level_fieldsets[fieldset_key]["fieldsets"][sub_key][
"fields"
].append(field_name)
for fieldset in top_level_fieldsets.values():
nested_fieldsets_list = []
for sub_fieldset in fieldset["fieldsets"].values():
if len(sub_fieldset["fields"]) == 1:
# If nested fieldset has only one field, move it to parent
fieldset["fields"].append(sub_fieldset["fields"][0])
else:
# Keep as nested fieldset with proper title stripping
title = f"{fieldset['title']}: {sub_fieldset['title']}: "
for field in sub_fieldset["fields"]:
self.strip_title(field, title)
sub_fieldset["is_advanced"] = self.are_all_fields_advanced(
sub_fieldset["fields"]
)
nested_fieldsets_list.append(sub_fieldset)
fieldset["fieldsets"] = nested_fieldsets_list
total_fields = len(fieldset["fields"]) + len(nested_fieldsets_list)
if total_fields == 1 and len(fieldset["fields"]) == 1:
others.append(fieldset["fields"][0])
else:
title = f"{fieldset['title']}: "
for field in fieldset["fields"]:
self.strip_title(field, title)
all_fields = fieldset["fields"][:]
for sub_fieldset in nested_fieldsets_list:
all_fields.extend(sub_fieldset["fields"])
fieldset["has_mandatory"] = self.has_mandatory_fields(all_fields)
fieldset["is_advanced"] = self.are_all_fields_advanced(all_fields)
fieldsets.append(fieldset)
# Add 'others' tab if there are any fields
if others:
fieldsets.append(
{
"title": "Others",
"fields": others,
"fieldsets": [],
"has_mandatory": self.has_mandatory_fields(others),
"is_advanced": self.are_all_fields_advanced(others),
}
)
if hidden_spec_fields:
fieldsets.append(
{
"title": "Advanced",
"fields": hidden_spec_fields,
"fieldsets": [],
"hidden": True,
"has_mandatory": self.has_mandatory_fields(hidden_spec_fields),
}
)
fieldsets.sort(key=lambda f: f.get("hidden", False))
return fieldsets
def get_nested_data(self):
"""
Builds the original nested JSON structure from flat form data.
Form fields are named with dot notation (e.g., 'spec.replicas')
"""
result = {}
for field_name, value in self.cleaned_data.items():
if value is None or value == "":
continue
parts = field_name.split(".")
current = result
# Navigate through the nested structure
for i, part in enumerate(parts):
if i == len(parts) - 1:
# Last part, set the value
current[part] = value
else:
# Create nested dict if it doesn't exist
if part not in current:
current[part] = {}
current = current[part]
return result
def clean(self):
cleaned_data = super().clean()
self.validate_nested_data(
self.get_nested_data().get("spec", {}), self.schema["properties"]["spec"]
)
return cleaned_data
def validate_nested_data(self, data, schema):
"""Validate data against the provided OpenAPI v3 schema"""
# TODO: actually validate the nested data.
# TODO: get jsonschema to give us a path to the failing field rather than just an error message,
# then add the validation error to that field (self.add_error())
# try:
# validate(instance=data, schema=schema)
# except Exception as e:
# raise forms.ValidationError(f"Validation error: {e.message}")
pass
def generate_model_form_class(model, advanced_fields=None):
meta_attrs = {
"model": model,
"fields": "__all__",
}
fields = {
"Meta": type("Meta", (object,), meta_attrs),
"__module__": "crd_models",
"ADVANCED_FIELDS": advanced_fields or [],
}
class_name = f"{model.__name__}ModelForm"
return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields)

View file

@ -0,0 +1,31 @@
from servala.core.crd.forms import (
CrdModelFormMixin,
CustomFormMixin,
FormGeneratorMixin,
generate_custom_form_class,
generate_model_form_class,
)
from servala.core.crd.models import (
CRDModel,
build_object_fields,
duplicate_field,
generate_django_model,
get_django_field,
unnest_data,
)
from servala.core.crd.utils import deslugify
__all__ = [
"CrdModelFormMixin",
"CustomFormMixin",
"FormGeneratorMixin",
"generate_django_model",
"generate_model_form_class",
"generate_custom_form_class",
"CRDModel",
"build_object_fields",
"duplicate_field",
"get_django_field",
"unnest_data",
"deslugify",
]

View file

@ -0,0 +1,479 @@
from contextlib import suppress
from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator
from django.forms.models import ModelForm, ModelFormMetaclass
from servala.core.crd.utils import deslugify
from servala.core.models import ControlPlaneCRD
from servala.frontend.forms.widgets import DynamicArrayWidget, NumberInputWithAddon
# Fields that must be present in every form
MANDATORY_FIELDS = ["name"]
# Default field configurations - fields that can be included with just a mapping
# to avoid administrators having to duplicate common information
DEFAULT_FIELD_CONFIGS = {
"name": {
"type": "text",
"label": "Instance Name",
"help_text": "Unique name for the new instance",
"required": True,
"max_length": 63,
},
"spec.parameters.service.fqdn": {
"type": "array",
"label": "FQDNs",
"help_text": "Domain names for accessing this service",
"required": False,
},
"spec.parameters.size.disk": {
"type": "number",
"label": "Disk size",
"addon_text": "Gi",
},
}
class FormGeneratorMixin:
"""Shared base class for ModelForm classes based on our generated CRD models.
There are two relevant child classes:
- CrdModelFormMixin: For fully auto-generated forms from the spec
- CustomFormMixin: For forms built from form_config settings.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if "context" in self.fields:
self.fields["context"].widget = forms.HiddenInput()
if crd := self.initial.get("context"):
crd = getattr(crd, "pk", crd) # can be int or object
self.fields["context"].queryset = ControlPlaneCRD.objects.filter(pk=crd)
if self.instance and hasattr(self.instance, "name") and self.instance.name:
if "name" in self.fields:
self.fields["name"].disabled = True
self.fields["name"].widget = forms.HiddenInput()
def has_mandatory_fields(self, field_list):
for field_name in field_list:
if field_name in self.fields and self.fields[field_name].required:
return True
return False
class CrdModelFormMixin(FormGeneratorMixin):
HIDDEN_FIELDS = [
"spec.compositeDeletePolicy",
"spec.compositionRef",
"spec.compositionRevisionRef",
"spec.compositionRevisionSelector",
"spec.compositionSelector",
"spec.compositionUpdatePolicy",
"spec.parameters.monitoring.alertmanagerConfigRef",
"spec.parameters.monitoring.alertmanagerConfigSecretRef",
"spec.parameters.network.serviceType",
"spec.parameters.scheduling",
"spec.parameters.security",
"spec.parameters.size.cpu",
"spec.parameters.size.memory",
"spec.parameters.size.requests.cpu",
"spec.parameters.size.requests.memory",
"spec.publishConnectionDetailsTo",
"spec.resourceRef",
"spec.writeConnectionSecretToRef",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.schema = self._meta.model.SCHEMA
for name, field in self.fields.items():
if name in self.HIDDEN_FIELDS or any(
name.startswith(f) for f in self.HIDDEN_FIELDS
):
field.widget = forms.HiddenInput()
field.required = False
def strip_title(self, field_name, label):
field = self.fields[field_name]
if field and field.label and (position := field.label.find(label)) != -1:
field.label = field.label[position + len(label) :]
def get_fieldsets(self):
fieldsets = []
# General fieldset for non-spec fields
general_fields = [
field_name
for field_name in self.fields.keys()
if not field_name.startswith("spec.")
]
if general_fields:
fieldset = {
"title": "General",
"fields": general_fields,
"fieldsets": [],
"has_mandatory": self.has_mandatory_fields(general_fields),
}
if all(
[
isinstance(self.fields[field].widget, forms.HiddenInput)
for field in general_fields
]
):
fieldset["hidden"] = True
fieldsets.append(fieldset)
# Process spec fields
others = []
top_level_fieldsets = {}
hidden_spec_fields = []
for field_name in self.fields:
if field_name.startswith("spec."):
if isinstance(self.fields[field_name].widget, forms.HiddenInput):
hidden_spec_fields.append(field_name)
continue
parts = field_name.split(".")
if len(parts) == 2:
# Top-level spec field
others.append(field_name)
elif len(parts) == 3:
# Second-level field - promote to top-level fieldset
fieldset_key = f"{parts[1]}.{parts[2]}"
if not top_level_fieldsets.get(fieldset_key):
top_level_fieldsets[fieldset_key] = {
"fields": [],
"fieldsets": {},
"title": f"{deslugify(parts[2])}",
}
top_level_fieldsets[fieldset_key]["fields"].append(field_name)
else:
# Third-level and deeper - create nested fieldsets
fieldset_key = f"{parts[1]}.{parts[2]}"
if not top_level_fieldsets.get(fieldset_key):
top_level_fieldsets[fieldset_key] = {
"fields": [],
"fieldsets": {},
"title": f"{deslugify(parts[2])}",
}
sub_key = parts[3]
if not top_level_fieldsets[fieldset_key]["fieldsets"].get(sub_key):
top_level_fieldsets[fieldset_key]["fieldsets"][sub_key] = {
"title": deslugify(sub_key),
"fields": [],
}
top_level_fieldsets[fieldset_key]["fieldsets"][sub_key][
"fields"
].append(field_name)
for fieldset in top_level_fieldsets.values():
nested_fieldsets_list = []
for sub_fieldset in fieldset["fieldsets"].values():
if len(sub_fieldset["fields"]) == 1:
# If nested fieldset has only one field, move it to parent
fieldset["fields"].append(sub_fieldset["fields"][0])
else:
# Keep as nested fieldset with proper title stripping
title = f"{fieldset['title']}: {sub_fieldset['title']}: "
for field in sub_fieldset["fields"]:
self.strip_title(field, title)
nested_fieldsets_list.append(sub_fieldset)
fieldset["fieldsets"] = nested_fieldsets_list
total_fields = len(fieldset["fields"]) + len(nested_fieldsets_list)
if total_fields == 1 and len(fieldset["fields"]) == 1:
others.append(fieldset["fields"][0])
else:
title = f"{fieldset['title']}: "
for field in fieldset["fields"]:
self.strip_title(field, title)
all_fields = fieldset["fields"][:]
for sub_fieldset in nested_fieldsets_list:
all_fields.extend(sub_fieldset["fields"])
fieldset["has_mandatory"] = self.has_mandatory_fields(all_fields)
fieldsets.append(fieldset)
# Add 'others' tab if there are any fields
if others:
fieldsets.append(
{
"title": "Others",
"fields": others,
"fieldsets": [],
"has_mandatory": self.has_mandatory_fields(others),
}
)
if hidden_spec_fields:
fieldsets.append(
{
"title": "Advanced",
"fields": hidden_spec_fields,
"fieldsets": [],
"hidden": True,
"has_mandatory": self.has_mandatory_fields(hidden_spec_fields),
}
)
fieldsets.sort(key=lambda f: f.get("hidden", False))
return fieldsets
def get_nested_data(self):
"""
Builds the original nested JSON structure from flat form data.
Form fields are named with dot notation (e.g., 'spec.replicas')
"""
result = {}
for field_name, value in self.cleaned_data.items():
if value is None or value == "":
continue
parts = field_name.split(".")
current = result
# Navigate through the nested structure
for i, part in enumerate(parts):
if i == len(parts) - 1:
# Last part, set the value
current[part] = value
else:
# Create nested dict if it doesn't exist
if part not in current:
current[part] = {}
current = current[part]
return result
def clean(self):
cleaned_data = super().clean()
self.validate_nested_data(
self.get_nested_data().get("spec", {}), self.schema["properties"]["spec"]
)
return cleaned_data
def validate_nested_data(self, data, schema):
"""Validate data against the provided OpenAPI v3 schema"""
# TODO: actually validate the nested data.
# TODO: get jsonschema to give us a path to the failing field rather than just an error message,
# then add the validation error to that field (self.add_error())
# try:
# validate(instance=data, schema=schema)
# except Exception as e:
# raise forms.ValidationError(f"Validation error: {e.message}")
pass
def generate_model_form_class(model):
meta_attrs = {
"model": model,
"fields": "__all__",
}
fields = {
"Meta": type("Meta", (object,), meta_attrs),
"__module__": "crd_models",
}
class_name = f"{model.__name__}ModelForm"
return ModelFormMetaclass(class_name, (CrdModelFormMixin, ModelForm), fields)
class CustomFormMixin(FormGeneratorMixin):
"""
Base for custom (user-friendly) forms generated from ServiceDefinition.form_config.
"""
IS_CUSTOM_FORM = True
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._apply_field_config()
if (
self.instance
and hasattr(self.instance, "name")
and self.instance.name
and "name" in self.fields
):
self.fields["name"].widget = forms.HiddenInput()
self.fields["name"].disabled = True
self.fields.pop("context", None)
def _apply_field_config(self):
for fieldset in self.form_config.get("fieldsets", []):
for fc in fieldset.get("fields", []):
field_name = fc.get("controlplane_field_mapping")
if field_name not in self.fields:
continue
field_config = fc.copy()
# Merge with defaults if field has default config
if field_name in DEFAULT_FIELD_CONFIGS:
field_config = DEFAULT_FIELD_CONFIGS[field_name].copy()
for key, value in fc.items():
if value or (value is False):
field_config[key] = value
field = self.fields[field_name]
field_type = field_config.get("type")
field.label = field_config.get("label", field_name)
field.help_text = field_config.get("help_text", "")
field.required = field_config.get("required", False)
if field_type == "textarea":
field.widget = forms.Textarea(
attrs={"rows": field_config.get("rows", 4)}
)
elif field_type == "array":
field.widget = DynamicArrayWidget()
elif field_type == "choice":
if hasattr(field, "choices") and field.choices:
field._controlplane_choices = list(field.choices)
if custom_choices := field_config.get("choices"):
field.choices = [tuple(choice) for choice in custom_choices]
if field_type == "number":
min_val = field_config.get("min_value")
max_val = field_config.get("max_value")
unit = field_config.get("addon_text")
if unit:
field.widget = NumberInputWithAddon(addon_text=unit)
field.addon_text = unit
value = self.initial.get(field_name)
if value and isinstance(value, str) and value.endswith(unit):
numeric_value = value[: -len(unit)]
with suppress(ValueError):
if "." in numeric_value:
self.initial[field_name] = float(numeric_value)
else:
self.initial[field_name] = int(numeric_value)
validators = []
if min_val is not None:
validators.append(MinValueValidator(min_val))
field.widget.attrs["min"] = min_val
if max_val is not None:
validators.append(MaxValueValidator(max_val))
field.widget.attrs["max"] = max_val
if validators:
field.validators.extend(validators)
if "default_value" in field_config and field.initial is None:
field.initial = field_config["default_value"]
if field_type in ("text", "textarea") and field_config.get(
"max_length"
):
field.max_length = field_config.get("max_length")
if hasattr(field.widget, "attrs"):
field.widget.attrs["maxlength"] = field_config.get("max_length")
field.controlplane_field_mapping = field_name
def get_fieldsets(self):
fieldsets = []
for fieldset_config in self.form_config.get("fieldsets", []):
field_names = [
f["controlplane_field_mapping"]
for f in fieldset_config.get("fields", [])
]
fieldset = {
"title": fieldset_config.get("title", "General"),
"fields": field_names,
"fieldsets": [],
"has_mandatory": self.has_mandatory_fields(field_names),
}
fieldsets.append(fieldset)
return fieldsets
def clean(self):
cleaned_data = super().clean()
for field_name, field in self.fields.items():
if hasattr(field, "_controlplane_choices"):
value = cleaned_data.get(field_name)
if value:
valid_values = [choice[0] for choice in field._controlplane_choices]
if value not in valid_values:
self.add_error(
field_name,
forms.ValidationError(
f"'{value}' is not a valid choice. "
f"Must be one of: {valid_values.join(', ')}"
),
)
return cleaned_data
def get_nested_data(self):
nested = {}
for field_name in self.fields.keys():
if field_name == "context":
value = self.cleaned_data.get(field_name)
if value is not None:
nested[field_name] = value
continue
mapping = field_name
value = self.cleaned_data.get(field_name)
field = self.fields[field_name]
if addon_text := getattr(field, "addon_text", None):
value = f"{value}{addon_text}"
parts = mapping.split(".")
current = nested
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
return nested
def generate_custom_form_class(form_config, model):
"""
Generate a custom (user-friendly) form class from form_config JSON.
"""
field_list = ["context", "name"]
for fieldset in form_config.get("fieldsets", []):
for field_config in fieldset.get("fields", []):
field_name = field_config.get("controlplane_field_mapping")
if field_name:
field_list.append(field_name)
fields = {
"context": forms.ModelChoiceField(
queryset=ControlPlaneCRD.objects.none(),
required=True,
widget=forms.HiddenInput(),
),
}
meta_attrs = {
"model": model,
"fields": field_list,
}
form_fields = {
"Meta": type("Meta", (object,), meta_attrs),
"__module__": "crd_models",
"form_config": form_config,
**fields,
}
class_name = f"{model.__name__}CustomForm"
return ModelFormMetaclass(class_name, (CustomFormMixin, ModelForm), form_fields)

View file

@ -0,0 +1,167 @@
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from servala.core.crd.utils import deslugify
from servala.core.models import ServiceInstance
from servala.frontend.forms.widgets import DynamicArrayField
class CRDModel(models.Model):
"""Base class for all virtual CRD models"""
def __init__(self, **kwargs):
if spec := kwargs.pop("spec", None):
kwargs.update(unnest_data({"spec": spec}))
super().__init__(**kwargs)
class Meta:
abstract = True
def generate_django_model(schema, group, version, kind):
"""
Generates a virtual Django model from a Kubernetes CRD's OpenAPI v3 schema.
"""
# We always need these three fields to know our own name and our full namespace
model_fields = {"__module__": "crd_models"}
for field_name in ("name", "context"):
model_fields[field_name] = duplicate_field(field_name, ServiceInstance)
# All other fields are generated from the schema, except for the
# resourceRef object
spec = schema["properties"].get("spec") or {}
spec["properties"].pop("resourceRef", None)
model_fields.update(build_object_fields(spec, "spec", parent_required=False))
# Store the original schema on the model class
model_fields["SCHEMA"] = schema
meta_class = type("Meta", (), {"app_label": "crd_models"})
model_fields["Meta"] = meta_class
# create the model class
model_name = kind
model_class = type(model_name, (CRDModel,), model_fields)
return model_class
def duplicate_field(field_name, model):
field = model._meta.get_field(field_name)
new_field = type(field).__new__(type(field))
new_field.__dict__.update(field.__dict__)
new_field.model = None
new_field.auto_created = False
return new_field
def build_object_fields(schema, name, verbose_name_prefix=None, parent_required=False):
required_fields = schema.get("required") or []
properties = schema.get("properties") or {}
fields = {}
for field_name, field_schema in properties.items():
is_required = field_name in required_fields or parent_required
full_name = f"{name}.{field_name}"
result = get_django_field(
field_schema,
is_required,
field_name,
full_name,
verbose_name_prefix=verbose_name_prefix,
)
if isinstance(result, dict):
fields.update(result)
else:
fields[full_name] = result
return fields
def get_django_field(
field_schema, is_required, field_name, full_name, verbose_name_prefix=None
):
field_type = field_schema.get("type") or "string"
format = field_schema.get("format")
verbose_name_prefix = verbose_name_prefix or ""
verbose_name = f"{verbose_name_prefix} {deslugify(field_name)}".strip()
# Pass down the requirement status from parent to child fields
kwargs = {
"blank": not is_required, # All fields are optional by default
"null": not is_required,
"help_text": field_schema.get("description"),
"validators": [],
"verbose_name": verbose_name,
"default": field_schema.get("default"),
}
if minimum := field_schema.get("minimum"):
kwargs["validators"].append(MinValueValidator(minimum))
if maximum := field_schema.get("maximum"):
kwargs["validators"].append(MaxValueValidator(maximum))
if field_type == "string":
if format == "date-time":
return models.DateTimeField(**kwargs)
elif format == "date":
return models.DateField(**kwargs)
else:
max_length = field_schema.get("max_length") or 255
if pattern := field_schema.get("pattern"):
kwargs["validators"].append(RegexValidator(regex=pattern))
if choices := field_schema.get("enum"):
kwargs["choices"] = ((choice, choice) for choice in choices)
return models.CharField(max_length=max_length, **kwargs)
elif field_type == "integer":
return models.IntegerField(**kwargs)
elif field_type == "number":
return models.FloatField(**kwargs)
elif field_type == "boolean":
return models.BooleanField(**kwargs)
elif field_type == "object":
# Here we pass down the requirement status to nested objects
return build_object_fields(
field_schema,
full_name,
verbose_name_prefix=f"{verbose_name}:",
parent_required=is_required,
)
elif field_type == "array":
kwargs["help_text"] = field_schema.get("description") or _("List of values")
field = models.JSONField(**kwargs)
formfield_kwargs = {
"label": field.verbose_name,
"required": not field.blank,
}
array_validation = {}
if min_items := field_schema.get("min_items"):
array_validation["min_items"] = min_items
if max_items := field_schema.get("max_items"):
array_validation["max_items"] = max_items
if unique_items := field_schema.get("unique_items"):
array_validation["unique_items"] = unique_items
if items_schema := field_schema.get("items"):
array_validation["items_schema"] = items_schema
if array_validation:
formfield_kwargs["array_validation"] = array_validation
field.formfield = lambda: DynamicArrayField(**formfield_kwargs)
return field
return models.CharField(max_length=255, **kwargs)
def unnest_data(data):
result = {}
def _flatten_dict(d, parent_key=""):
for key, value in d.items():
new_key = f"{parent_key}.{key}" if parent_key else key
if isinstance(value, dict):
_flatten_dict(value, new_key)
else:
result[new_key] = value
_flatten_dict(data)
return result

View file

@ -0,0 +1,115 @@
import re
def deslugify(title):
"""
Convert camelCase, PascalCase, or snake_case to human-readable title.
Handles known acronyms (e.g., postgreSQLParameters -> PostgreSQL Parameters).
"""
ACRONYMS = {
# Database systems
"SQL": "SQL",
"MYSQL": "MySQL",
"POSTGRESQL": "PostgreSQL",
"MARIADB": "MariaDB",
"MSSQL": "MSSQL",
"MONGODB": "MongoDB",
"REDIS": "Redis",
# Protocols
"HTTP": "HTTP",
"HTTPS": "HTTPS",
"FTP": "FTP",
"SFTP": "SFTP",
"SSH": "SSH",
"TLS": "TLS",
"SSL": "SSL",
# APIs
"API": "API",
"REST": "REST",
"GRPC": "gRPC",
"GRAPHQL": "GraphQL",
# Networking
"URL": "URL",
"URI": "URI",
"FQDN": "FQDN",
"DNS": "DNS",
"IP": "IP",
"TCP": "TCP",
"UDP": "UDP",
# Data formats
"JSON": "JSON",
"XML": "XML",
"YAML": "YAML",
"CSV": "CSV",
"HTML": "HTML",
"CSS": "CSS",
# Hardware
"CPU": "CPU",
"RAM": "RAM",
"GPU": "GPU",
"SSD": "SSD",
"HDD": "HDD",
# Identifiers
"ID": "ID",
"UUID": "UUID",
"GUID": "GUID",
"ARN": "ARN",
# Cloud providers
"AWS": "AWS",
"GCP": "GCP",
"AZURE": "Azure",
"IBM": "IBM",
# Kubernetes/Cloud
"DB": "DB",
"PVC": "PVC",
"PV": "PV",
"VPN": "VPN",
# Auth
"OS": "OS",
"LDAP": "LDAP",
"SAML": "SAML",
"OAUTH": "OAuth",
"JWT": "JWT",
# AWS Services
"S3": "S3",
"EC2": "EC2",
"RDS": "RDS",
"EBS": "EBS",
"IAM": "IAM",
}
if "_" in title:
# Handle snake_case
title = title.replace("_", " ")
words = title.split()
else:
# Handle camelCase/PascalCase with smart splitting
# This regex splits on:
# - Transition from lowercase to uppercase (camelCase)
# - Transition from multiple uppercase to an uppercase followed by lowercase (SQLParameters -> SQL Parameters)
words = re.findall(r"[A-Z]+(?=[A-Z][a-z]|\b)|[A-Z][a-z]+|[a-z]+|[0-9]+", title)
# Merge adjacent words if they form a known compound acronym (e.g., postgre + SQL = PostgreSQL)
merged_words = []
i = 0
while i < len(words):
if i < len(words) - 1:
# Check if current word + next word form a known acronym
combined = (words[i] + words[i + 1]).upper()
if combined in ACRONYMS:
merged_words.append(combined)
i += 2
continue
merged_words.append(words[i])
i += 1
# Capitalize each word, using proper casing for known acronyms
result = []
for word in merged_words:
word_upper = word.upper()
if word_upper in ACRONYMS:
result.append(ACRONYMS[word_upper])
else:
result.append(word.capitalize())
return " ".join(result)

View file

@ -1,7 +1,12 @@
import json
from pathlib import Path
import jsonschema
from django import forms
from django.utils.translation import gettext_lazy as _
from django_jsonform.widgets import JSONFormWidget
from servala.core.crd.forms import DEFAULT_FIELD_CONFIGS, MANDATORY_FIELDS
from servala.core.models import ControlPlane, ServiceDefinition
CONTROL_PLANE_USER_INFO_SCHEMA = {
@ -96,6 +101,12 @@ class ControlPlaneAdminForm(forms.ModelForm):
return super().save(*args, **kwargs)
def fields_empty(fields):
if not fields:
return True
return all(not field.get("controlplane_field_mapping") for field in fields)
class ServiceDefinitionAdminForm(forms.ModelForm):
api_group = forms.CharField(
required=False,
@ -124,6 +135,10 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
self.fields["api_version"].initial = api_def.get("version", "")
self.fields["api_kind"].initial = api_def.get("kind", "")
schema_path = Path(__file__).parent / "schemas" / "form_config_schema.json"
with open(schema_path) as f:
self.form_config_schema = json.load(f)
def clean(self):
cleaned_data = super().clean()
@ -151,8 +166,250 @@ class ServiceDefinitionAdminForm(forms.ModelForm):
api_def["kind"] = api_kind
cleaned_data["api_definition"] = api_def
form_config = cleaned_data.get("form_config")
# Convert empty form_config to None (no custom form)
if form_config:
if not form_config.get("fieldsets") or all(
fields_empty(fieldset.get("fields"))
for fieldset in form_config.get("fieldsets")
):
form_config = None
cleaned_data["form_config"] = None
if form_config:
form_config = self._normalize_form_config_types(form_config)
cleaned_data["form_config"] = form_config
try:
jsonschema.validate(
instance=form_config, schema=self.form_config_schema
)
except jsonschema.ValidationError as e:
raise forms.ValidationError(
{
"form_config": _("Invalid form configuration: {}").format(
e.message
)
}
)
except jsonschema.SchemaError as e:
raise forms.ValidationError(
{"form_config": _("Schema error: {}").format(e.message)}
)
self._validate_field_mappings(form_config, cleaned_data)
return cleaned_data
def _normalize_form_config_types(self, form_config):
"""
Normalize form_config by converting string representations of numbers
to actual integers/floats. The JSON form widget sends all values
as strings, but the schema expects proper types.
"""
if not isinstance(form_config, dict):
return form_config
integer_fields = ["max_length", "rows", "min_values", "max_values"]
number_fields = ["min_value", "max_value"]
for fieldset in form_config.get("fieldsets", []):
for field in fieldset.get("fields", []):
for field_name in integer_fields:
if field_name in field and field[field_name] is not None:
value = field[field_name]
if isinstance(value, str):
try:
field[field_name] = int(value) if value else None
except (ValueError, TypeError):
pass
for field_name in number_fields:
if field_name in field and field[field_name] is not None:
value = field[field_name]
if isinstance(value, str):
try:
field[field_name] = (
int(value) if "." not in value else float(value)
)
except (ValueError, TypeError):
pass
return form_config
def _validate_field_mappings(self, form_config, cleaned_data):
if not self.instance.pk:
return
crd = self.instance.offering_control_planes.all().first()
if not crd:
return
schema = None
try:
schema = crd.resource_schema
except Exception:
pass
if not schema or not (spec_schema := schema.get("properties", {}).get("spec")):
return
valid_paths = self._extract_field_paths(spec_schema, "spec") | {"name"}
included_mappings = set()
errors = []
for fieldset in form_config.get("fieldsets", []):
for field in fieldset.get("fields", []):
mapping = field.get("controlplane_field_mapping")
included_mappings.add(mapping)
# Validate that fields without defaults have required properties
if mapping not in DEFAULT_FIELD_CONFIGS:
if not field.get("label"):
errors.append(
_(
"Field with mapping '{}' must have a 'label' property "
"(or use a mapping with default config)"
).format(mapping)
)
if not field.get("type"):
errors.append(
_(
"Field with mapping '{}' must have a 'type' property "
"(or use a mapping with default config)"
).format(mapping)
)
if mapping and mapping not in valid_paths:
field_name = field.get("label", field.get("name", mapping))
errors.append(
_(
"Field '{}' has invalid mapping '{}'. Valid paths are: {}"
).format(
field_name,
mapping,
", ".join(sorted(valid_paths)[:10])
+ ("..." if len(valid_paths) > 10 else ""),
)
)
if field.get("type") == "choice" and field.get("choices"):
self._validate_choice_field(
field, mapping, spec_schema, "spec", errors
)
for mandatory_field in MANDATORY_FIELDS:
if mandatory_field not in included_mappings:
errors.append(
_(
"Required field '{}' must be included in the form configuration"
).format(mandatory_field)
)
if errors:
raise forms.ValidationError({"form_config": errors})
def _validate_choice_field(self, field, mapping, spec_schema, prefix, errors):
if not mapping:
return
field_name = field.get("label", mapping)
custom_choices = field.get("choices", [])
# Single-element choices [value] are transformed to [value, value]
for i, choice in enumerate(custom_choices):
if not isinstance(choice, (list, tuple)):
errors.append(
_(
"Field '{}': Choice at index {} must be a list or tuple, "
"but got: {}"
).format(field_name, i, repr(choice))
)
return
choice_len = len(choice)
if choice_len == 1:
custom_choices[i] = [choice[0], choice[0]]
elif choice_len == 0 or choice_len > 2:
errors.append(
_(
"Field '{}': Choice at index {} must have 1 or 2 elements "
"(got {}): {}"
).format(field_name, i, choice_len, repr(choice))
)
return
field_schema = self._get_field_schema(spec_schema, mapping, prefix)
if not field_schema:
return
control_plane_choices = field_schema.get("enum", [])
if not control_plane_choices:
return
custom_choice_values = [choice[0] for choice in custom_choices]
invalid_choices = [
value
for value in custom_choice_values
if value not in control_plane_choices
]
if invalid_choices:
errors.append(
_(
"Field '{}' has invalid choice values: {}. "
"Valid choices from control plane are: {}"
).format(
field_name,
", ".join(f"'{c}'" for c in invalid_choices),
", ".join(f"'{c}'" for c in control_plane_choices),
)
)
def _get_field_schema(self, schema, field_path, prefix):
if not field_path or not schema:
return None
if field_path.startswith(prefix + "."):
field_path = field_path[len(prefix) + 1 :]
parts = field_path.split(".")
current_schema = schema
for part in parts:
if not isinstance(current_schema, dict):
return None
properties = current_schema.get("properties", {})
if part not in properties:
return None
current_schema = properties[part]
return current_schema
def _extract_field_paths(self, schema, prefix=""):
paths = set()
if not isinstance(schema, dict):
return paths
if "type" in schema and schema["type"] != "object":
if prefix:
paths.add(prefix)
if schema.get("properties"):
for prop_name, prop_schema in schema["properties"].items():
new_prefix = f"{prefix}.{prop_name}" if prefix else prop_name
paths.add(new_prefix)
paths.update(self._extract_field_paths(prop_schema, new_prefix))
if schema.get("type") == "array" and "items" in schema:
if prefix:
paths.add(prefix)
return paths
def save(self, *args, **kwargs):
self.instance.api_definition = self.cleaned_data["api_definition"]
return super().save(*args, **kwargs)

View file

@ -0,0 +1,32 @@
# Generated by Django 5.2.7 on 2025-10-31 10:40
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0012_convert_user_info_to_array"),
]
operations = [
migrations.RemoveField(
model_name="servicedefinition",
name="advanced_fields",
),
migrations.AlterField(
model_name="organization",
name="name",
field=models.CharField(
max_length=32,
validators=[
django.core.validators.RegexValidator(
message="Organization name can only contain letters, numbers, and spaces.",
regex="^[A-Za-z0-9\\s]+$",
)
],
verbose_name="Name",
),
),
]

View file

@ -0,0 +1,27 @@
# Generated by Django 5.2.7 on 2025-10-31 10:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0012_remove_advanced_fields"),
]
operations = [
migrations.AddField(
model_name="servicedefinition",
name="form_config",
field=models.JSONField(
blank=True,
help_text=(
"Optional custom form configuration. When provided, this configuration will "
"be used to render the service form instead of auto-generating it from the OpenAPI spec. "
'Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}'
),
null=True,
verbose_name="Form Configuration",
),
),
]

View file

@ -0,0 +1,44 @@
# Generated by Django 5.2.8 on 2025-11-12 09:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0013_add_form_config"),
]
operations = [
migrations.AddField(
model_name="organizationorigin",
name="billing_message",
field=models.TextField(
blank=True,
help_text="Optional message to display instead of billing address (e.g., 'You will be invoiced by Exoscale').",
verbose_name="Billing Message",
),
),
migrations.AddField(
model_name="organizationorigin",
name="hide_billing_address",
field=models.BooleanField(
default=False,
help_text="If enabled, the billing address will not be shown in the organization details view.",
verbose_name="Hide Billing Address",
),
),
migrations.AlterField(
model_name="controlplane",
name="user_info",
field=models.JSONField(
blank=True,
help_text=(
'Array of info objects: [{"title": "", "content": "", "help_text": ""}]. '
"The help_text field is optional and will be shown as a hover popover on an info icon."
),
null=True,
verbose_name="User Information",
),
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 5.2.8 on 2025-11-14 15:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0014_hide_billing_address"),
]
operations = [
migrations.AddField(
model_name="servicedefinition",
name="hide_expert_mode",
field=models.BooleanField(
default=False,
help_text=(
"When enabled, the 'Show Expert Mode' toggle will be hidden and only the custom form configuration will be available. "
"Only applies when a custom form configuration is provided."
),
verbose_name="Disable Expert Mode",
),
),
]

View file

@ -419,6 +419,20 @@ class OrganizationOrigin(ServalaModelMixin, models.Model):
"If set, this sale order will be used for new organizations with this origin."
),
)
hide_billing_address = models.BooleanField(
default=False,
verbose_name=_("Hide Billing Address"),
help_text=_(
"If enabled, the billing address will not be shown in the organization details view."
),
)
billing_message = models.TextField(
blank=True,
verbose_name=_("Billing Message"),
help_text=_(
"Optional message to display instead of billing address (e.g., 'You will be invoiced by Exoscale')."
),
)
class Meta:
verbose_name = _("Organization origin")

View file

@ -360,15 +360,23 @@ class ServiceDefinition(ServalaModelMixin, models.Model):
null=True,
blank=True,
)
advanced_fields = models.JSONField(
verbose_name=_("Advanced fields"),
form_config = models.JSONField(
verbose_name=_("Form Configuration"),
help_text=_(
"Array of field names that should be hidden behind an 'Advanced' toggle. "
"Use dot notation (e.g., ['spec.parameters.monitoring.enabled', 'spec.parameters.backup.schedule'])"
"Optional custom form configuration. When provided, this configuration will be used "
"to render the service form instead of auto-generating it from the OpenAPI spec. "
'Format: {"fieldsets": [{"title": "Section", "fields": [{...}]}]}'
),
null=True,
blank=True,
default=list,
)
hide_expert_mode = models.BooleanField(
default=False,
verbose_name=_("Disable Expert Mode"),
help_text=_(
"When enabled, the 'Show Expert Mode' toggle will be hidden and only the custom form "
"configuration will be available. Only applies when a custom form configuration is provided."
),
)
service = models.ForeignKey(
to="Service",
@ -510,9 +518,22 @@ class ControlPlaneCRD(ServalaModelMixin, models.Model):
if not self.django_model:
return
advanced_fields = self.service_definition.advanced_fields or []
return generate_model_form_class(
self.django_model, advanced_fields=advanced_fields
return generate_model_form_class(self.django_model)
@cached_property
def custom_model_form_class(self):
from servala.core.crd import generate_custom_form_class
if not self.django_model:
return
if not (
self.service_definition
and self.service_definition.form_config
and self.service_definition.form_config.get("fieldsets")
):
return
return generate_custom_form_class(
self.service_definition.form_config, self.django_model
)
@ -878,7 +899,6 @@ class ServiceInstance(ServalaModelMixin, models.Model):
return
return self.context.django_model(
name=self.name,
organization=self.organization,
context=self.context,
spec=self.spec,
# We pass -1 as ID in order to make it clear that a) this object exists (remotely),
@ -949,5 +969,21 @@ class ServiceInstance(ServalaModelMixin, models.Model):
except Exception as e:
return {"error": str(e)}
@property
def fqdn_url(self):
try:
fqdn = self.spec.get("parameters", {}).get("service", {}).get("fqdn")
if not fqdn:
return None
if isinstance(fqdn, list):
return fqdn[0]
elif isinstance(fqdn, str):
return fqdn
else:
return None
except (AttributeError, KeyError, IndexError):
return None
auditlog.register(ServiceInstance, exclude_fields=["updated_at"], serialize_data=True)

View file

@ -207,3 +207,19 @@ def get_invoice_addresses(user):
return invoice_addresses or []
except Exception:
return []
def create_helpdesk_ticket(title, description, partner_id=None, sale_order_id=None):
ticket_data = {
"name": title,
"team_id": settings.ODOO["HELPDESK_TEAM_ID"],
"description": description,
}
if partner_id:
ticket_data["partner_id"] = partner_id
if sale_order_id:
ticket_data["sale_order_id"] = sale_order_id
return CLIENT.execute("helpdesk.ticket", "create", [ticket_data])

View file

@ -0,0 +1,107 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Service Definition Form Configuration Schema",
"description": "Schema for custom form configuration in ServiceDefinition",
"type": "object",
"required": ["fieldsets"],
"properties": {
"fieldsets": {
"type": "array",
"description": "Array of fieldset objects defining form sections",
"minItems": 1,
"items": {
"type": "object",
"required": ["fields"],
"properties": {
"title": {
"type": "string",
"description": "Optional title for the fieldset/tab"
},
"fields": {
"type": "array",
"description": "Array of field definitions in this fieldset",
"minItems": 1,
"items": {
"type": "object",
"required": ["controlplane_field_mapping"],
"properties": {
"type": {
"type": "string",
"description": "Field type",
"enum": ["", "text", "email", "textarea", "number", "choice", "checkbox", "array"]
},
"label": {
"type": "string",
"description": "Human-readable field label"
},
"help_text": {
"type": "string",
"description": "Optional help text displayed below the field"
},
"required": {
"type": "boolean",
"description": "Whether the field is required",
"default": false
},
"controlplane_field_mapping": {
"type": "string",
"description": "Dot-notation path mapping to Kubernetes spec field (e.g., 'spec.parameters.service.fqdn')"
},
"max_length": {
"type": ["integer", "null"],
"description": "Maximum length for text/textarea fields",
"minimum": 1
},
"rows": {
"type": ["integer", "null"],
"description": "Number of rows for textarea fields",
"minimum": 1
},
"min_value": {
"type": ["number", "null"],
"description": "Minimum value for number fields"
},
"max_value": {
"type": ["number", "null"],
"description": "Maximum value for number fields"
},
"choices": {
"type": "array",
"description": "Array of [value, label] pairs for choice fields",
"items": {
"type": "array",
"items": {
"type": "string"
}
}
},
"min_values": {
"type": ["integer", "null"],
"description": "Minimum number of values for array fields",
"minimum": 0
},
"max_values": {
"type": ["integer", "null"],
"description": "Maximum number of values for array fields",
"minimum": 1
},
"validators": {
"type": "array",
"description": "Array of validator names (for future use)",
"items": {
"type": "string",
"enum": ["email", "fqdn", "url", "ipv4", "ipv6"]
}
},
"default_value": {
"type": "string",
"description": "Default value for the field when creating new instances"
}
}
}
}
}
}
}
}
}

View file

@ -8,7 +8,6 @@ from servala.core.models import Organization, OrganizationInvitation, Organizati
from servala.core.odoo import get_invoice_addresses, get_odoo_countries
from servala.frontend.forms.mixins import HtmxMixin
ORG_NAME_PATTERN = r"[\w\s\-.,&'()+]+"

View file

@ -2,6 +2,7 @@ import json
from django import forms
from django.core.exceptions import ValidationError
from django.forms.widgets import NumberInput
class DynamicArrayWidget(forms.Widget):
@ -216,3 +217,21 @@ class DynamicArrayField(forms.JSONField):
raise ValidationError(
f"Item {i + 1} must be one of: {', '.join(enum_values)}"
)
class NumberInputWithAddon(NumberInput):
"""
Widget for number input fields with a suffix add-on (e.g., "Gi", "MB").
Renders as a Bootstrap input-group with the suffix displayed as an add-on.
"""
template_name = "frontend/forms/number_input_with_addon.html"
def __init__(self, addon_text="", attrs=None):
super().__init__(attrs)
self.addon_text = addon_text
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
context["widget"]["addon_text"] = self.addon_text
return context

View file

@ -26,12 +26,14 @@
<div class="text-center mb-4">
<h5 class="text-primary mb-2">{% translate "Ready to get started?" %}</h5>
<p class="text-muted mb-0">
{% translate "Sign in to access your managed service instances and the Servala service catalog" %}
{% translate "Sign in to your account or create a new one to access your managed service instances and the Servala service catalog" %}
</p>
</div>
{% for provider in socialaccount_providers %}
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
<form method="post" action="{{ href }}" class="d-flex justify-content-center">
<form method="post"
action="{{ href }}"
class="d-flex justify-content-center">
{% csrf_token %}
{{ redirect_field }}
<button type="submit"
@ -40,7 +42,7 @@
style="border-radius: 12px;
box-shadow: 0 4px 15px rgba(154, 99, 236, 0.2);
background: linear-gradient(135deg, var(--bs-primary), #8B5CF6)">
<span>{% translate "Sign in with VSHN Account" %}</span>
<span>{% translate "Sign in or Register" %}</span>
</button>
</form>
{% endfor %}

View file

@ -1,5 +1,5 @@
<div class="dynamic-array-widget"
id="{{ widget.attrs.id|default:'id_'|add:widget.name }}_container"
id="{{ widget.name }}_container"
data-name="{{ widget.name }}"
{% for name, value in widget.attrs.items %}{% if value is not False and name != "id" and name != "class" %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}
{% endif %}

View file

@ -14,7 +14,7 @@
{% endif %}
{% if field.use_fieldset %}</fieldset>{% endif %}
{% for text in field.errors %}<div class="invalid-feedback">{{ text }}</div>{% endfor %}
{% if field.help_text %}
{% if field.help_text and not field.is_hidden and not field.field.widget.input_type == "hidden" %}
<small class="form-text text-muted"
{% if field.auto_id %}id="{{ field.auto_id }}_helptext"{% endif %}>{{ field.help_text|safe }}</small>
{% endif %}

View file

@ -0,0 +1,11 @@
<div class="input-group">
<input type="{{ widget.type }}"
name="{{ widget.name }}"
{% if widget.value != None %}value="{{ widget.value }}"{% endif %}
{% if widget.attrs.id %}id="{{ widget.attrs.id }}"{% endif %}
{% for name, value in widget.attrs.items %} {% if value is not False and name != "id" %} {{ name }}{% if value is not True %}="{{ value }}"{% endif %}
{% endif %}
{% endfor %}
class="form-control{% if widget.attrs.class %} {{ widget.attrs.class }}{% endif %}" />
<span class="input-group-text">{{ widget.addon_text }}</span>
</div>

View file

@ -7,6 +7,15 @@
{% endblock html_title %}
{% block page_title_extra %}
<div>
{% if instance.fqdn_url %}
<a href="https://{{ instance.fqdn_url }}"
target="_blank"
rel="noopener noreferrer"
class="btn btn-success me-1 mb-1">
<i class="bi bi-box-arrow-up-right me-1"></i>
{% translate "Open" %}
</a>
{% endif %}
{% if has_change_permission %}
<a href="{{ instance.urls.update }}" class="btn btn-primary me-1 mb-1">{% translate "Edit" %}</a>
{% endif %}

View file

@ -13,7 +13,7 @@
<a href="{{ instance.urls.base }}" class="btn btn-secondary me-1 mb-1">{% translate "Back" %}</a>
{% endblock page_title_extra %}
{% partialdef service-form %}
{% if form %}
{% if form or custom_form %}
<div class="card">
<div class="card-header d-flex align-items-center"></div>
<div class="card-body">
@ -22,7 +22,7 @@
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
</div>
{% else %}
{% include "includes/tabbed_fieldset_form.html" with form=form %}
{% include "includes/tabbed_fieldset_form.html" with form=custom_form expert_form=form %}
{% endif %}
</div>
</div>
@ -31,7 +31,7 @@
{% block content %}
<section class="section">
<div class="card">
{% if not form %}
{% if not form and not custom_form %}
<div class="alert alert-warning" role="alert">
{% translate "Cannot update this service instance because its details could not be retrieved from the underlying system. It might have been deleted externally." %}
</div>

View file

@ -17,7 +17,7 @@
{% endif %}
{% endpartialdef %}
{% partialdef service-form %}
{% if service_form %}
{% if service_form or custom_service_form %}
<div class="card">
<div class="card-header d-flex align-items-center"></div>
<div class="card-body">
@ -26,7 +26,7 @@
{% translate "Oops! Something went wrong with the service form generation. Please try again later." %}
</div>
{% else %}
{% include "includes/tabbed_fieldset_form.html" with form=service_form %}
{% include "includes/tabbed_fieldset_form.html" with form=custom_service_form expert_form=service_form %}
{% endif %}
</div>
</div>
@ -42,7 +42,9 @@
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<div>
<strong>{% translate "Service Unavailable" %}</strong>
<p class="mb-0">{% translate "We currently cannot offer this service. Please check back later or contact support for more information." %}</p>
<p class="mb-0">
{% translate "We currently cannot offer this service. Please check back later or contact support for more information." %}
</p>
</div>
</div>
</div>

View file

@ -156,72 +156,85 @@
</div>
</div>
</div>
{% if form.instance.billing_entity and form.instance.billing_entity.odoo_data.invoice_address %}
{% if not form.instance.origin.hide_billing_address %}
{% if form.instance.billing_entity and form.instance.billing_entity.odoo_data.invoice_address %}
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Billing Address" %}</h4>
{% if form.instance.has_inherited_billing_entity %}
<p class="text-muted">
<small>{% translate "This billing address cannot be modified." %}</small>
</p>
{% endif %}
</div>
<div class="card-content">
<div class="card-body">
{% with odoo_data=form.instance.billing_entity.odoo_data %}
<div class="table-responsive">
<table class="table table-lg">
<tbody>
{% if odoo_data.invoice_address %}
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Invoice Contact Name" %}</span>
</th>
<td>{{ odoo_data.invoice_address.name|default:"" }}</td>
</tr>
<tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Street" %}</span>
</th>
<td>{{ odoo_data.invoice_address.street|default:"" }}</td>
</tr>
{% if odoo_data.invoice_address.street2 %}
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Street 2" %}</span>
</th>
<td>{{ odoo_data.invoice_address.street2 }}</td>
</tr>
{% endif %}
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "City" %}</span>
</th>
<td>{{ odoo_data.invoice_address.city|default:"" }}</td>
</tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "ZIP Code" %}</span>
</th>
<td>{{ odoo_data.invoice_address.zip|default:"" }}</td>
</tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Country" %}</span>
</th>
<td>{{ odoo_data.invoice_address.country_id.1|default:"" }}</td>
</tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Invoice Email" %}</span>
</th>
<td>{{ odoo_data.invoice_address.email|default:"" }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endwith %}
</div>
</div>
</div>
{% endif %}
{% elif form.instance.origin.billing_message %}
<div class="card">
<div class="card-header">
<h4 class="card-title">{% translate "Billing Address" %}</h4>
{% if form.instance.has_inherited_billing_entity %}
<p class="text-muted">
<small>{% translate "This billing address cannot be modified." %}</small>
</p>
{% endif %}
<h4 class="card-title">{% translate "Billing Information" %}</h4>
</div>
<div class="card-content">
<div class="card-body">
{% with odoo_data=form.instance.billing_entity.odoo_data %}
<div class="table-responsive">
<table class="table table-lg">
<tbody>
{% if odoo_data.invoice_address %}
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Invoice Contact Name" %}</span>
</th>
<td>{{ odoo_data.invoice_address.name|default:"" }}</td>
</tr>
<tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Street" %}</span>
</th>
<td>{{ odoo_data.invoice_address.street|default:"" }}</td>
</tr>
{% if odoo_data.invoice_address.street2 %}
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Street 2" %}</span>
</th>
<td>{{ odoo_data.invoice_address.street2 }}</td>
</tr>
{% endif %}
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "City" %}</span>
</th>
<td>{{ odoo_data.invoice_address.city|default:"" }}</td>
</tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "ZIP Code" %}</span>
</th>
<td>{{ odoo_data.invoice_address.zip|default:"" }}</td>
</tr>
<tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Country" %}</span>
</th>
<td>{{ odoo_data.invoice_address.country_id.1|default:"" }}</td>
</tr>
<th class="w-25">
<span class="d-flex mt-2">{% translate "Invoice Email" %}</span>
</th>
<td>{{ odoo_data.invoice_address.email|default:"" }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endwith %}
<p>{{ form.instance.origin.billing_message }}</p>
</div>
</div>
</div>

View file

@ -4,9 +4,7 @@
{% for info in control_plane.user_info %}
<div class="info-item mb-3">
<div class="d-flex align-items-center mb-1">
<small class="text-muted fw-semibold">
{{ info.title }}
</small>
<small class="text-muted fw-semibold">{{ info.title }}</small>
{% if info.help_text %}
<i class="bi bi-info-circle ms-1 text-muted"
data-bs-toggle="popover"
@ -14,7 +12,7 @@
data-bs-placement="top"
data-bs-content="{{ info.help_text }}"
style="cursor: help;
font-size: 0.875rem;"></i>
font-size: 0.875rem"></i>
{% endif %}
</div>
<div class="bg-light-subtle p-2 rounded">

View file

@ -130,7 +130,7 @@
{% else %}
<a href="{% url 'account_login' %}" class="sidebar-link">
<i class="bi bi-person-badge-fill"></i>
<span>{% translate 'Login' %}</span>
<span>{% translate 'Sign in' %}</span>
</a>
{% endif %}
<a href="#" class="burger-btn d-block d-xl-none">

View file

@ -26,6 +26,6 @@
{% else %}
<span></span>
{% endif %}
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "View Availability" %}</a>
<a href="{{ service.slug }}/" class="btn btn-light-primary">{% translate "Get It" %}</a>
</div>
</div>

View file

@ -6,68 +6,144 @@
{% if form_action %}action="{{ form_action }}"{% endif %}>
{% csrf_token %}
{% include "frontend/forms/errors.html" %}
{% if form.ADVANCED_FIELDS %}
<div class="mb-3">
<button type="button"
class="btn btn-sm btn-outline-secondary ml-auto d-block"
id="advanced-toggle"
data-bs-toggle="collapse"
data-bs-target=".advanced-field-group"
aria-expanded="false">
<i class="bi bi-gear"></i> {% translate "Show Advanced Options" %}
</button>
{% if form and expert_form and not hide_expert_mode %}
<div class="mb-3 text-end">
<a href="#"
class="text-muted small"
id="expert-mode-toggle"
style="text-decoration: none">{% translate "Show Expert Mode" %}</a>
</div>
{% endif %}
<ul class="nav nav-tabs" id="myTab" role="tablist">
{% for fieldset in form.get_fieldsets %}
{% if not fieldset.hidden %}
<li class="nav-item{% if fieldset.is_advanced %} advanced-field-group collapse{% endif %}"
role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
id="{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab"
data-bs-target="#{{ fieldset.title|slugify }}"
type="button"
role="tab"
aria-controls="{{ fieldset.title|slugify }}"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
{{ fieldset.title }}
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
</button>
</li>
{% endif %}
{% endfor %}
</ul>
<div class="tab-content" id="myTabContent">
{% for fieldset in form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
id="{{ fieldset.title|slugify }}"
role="tabpanel"
aria-labelledby="{{ fieldset.title|slugify }}-tab">
{% for field in fieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
{% for subfieldset in fieldset.fieldsets %}
{% if subfieldset.fields %}
<div {% if subfieldset.is_advanced %}class="advanced-field-group collapse"{% endif %}>
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
<div id="custom-form-container"
class="{% if form %}custom-crd-form{% else %}expert-crd-form{% endif %}">
{% if form and form.context %}{{ form.context }}{% endif %}
{% if form and form.get_fieldsets|length == 1 %}
{# Single fieldset - render without tabs #}
{% for fieldset in form.get_fieldsets %}
<div class="my-2">
{% for field in fieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
{% for subfieldset in fieldset.fieldsets %}
{% if subfieldset.fields %}
<div>
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
{% elif form %}
{# Multiple fieldsets or auto-generated form - render with tabs #}
<ul class="nav nav-tabs" id="myTab" role="tablist">
{% for fieldset in form.get_fieldsets %}
{% if not fieldset.hidden %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
id="{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab"
data-bs-target="#custom-{{ fieldset.title|slugify }}"
type="button"
role="tab"
aria-controls="custom-{{ fieldset.title|slugify }}"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
{{ fieldset.title }}
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
</button>
</li>
{% endif %}
{% endfor %}
</ul>
<div class="tab-content" id="myTabContent">
{% for fieldset in form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
id="custom-{{ fieldset.title|slugify }}"
role="tabpanel"
aria-labelledby="custom-{{ fieldset.title|slugify }}-tab">
{% for field in fieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
{% for subfieldset in fieldset.fieldsets %}
{% if subfieldset.fields %}
<div>
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
{% endfor %}
{% endif %}
</div>
{% if expert_form and not hide_expert_mode %}
<div id="expert-form-container"
class="expert-crd-form"
style="{% if form %}display:none{% endif %}">
{% if expert_form and expert_form.context %}{{ expert_form.context }}{% endif %}
<ul class="nav nav-tabs" id="expertTab" role="tablist">
{% for fieldset in expert_form.get_fieldsets %}
{% if not fieldset.hidden %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if forloop.first %}active{% endif %}{% if fieldset.has_mandatory %} has-mandatory{% endif %}"
id="expert-{{ fieldset.title|slugify }}-tab"
data-bs-toggle="tab"
data-bs-target="#expert-{{ fieldset.title|slugify }}"
type="button"
role="tab"
aria-controls="expert-{{ fieldset.title|slugify }}"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}">
{{ fieldset.title }}
{% if fieldset.has_mandatory %}<span class="mandatory-indicator">*</span>{% endif %}
</button>
</li>
{% endif %}
{% endfor %}
</ul>
<div class="tab-content" id="expertTabContent">
{% for fieldset in expert_form.get_fieldsets %}
<div class="tab-pane fade my-2 {% if fieldset.hidden %}d-none{% endif %}{% if forloop.first %}show active{% endif %}"
id="expert-{{ fieldset.title|slugify }}"
role="tabpanel"
aria-labelledby="expert-{{ fieldset.title|slugify }}-tab">
{% for field in fieldset.fields %}
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
{% for subfieldset in fieldset.fieldsets %}
{% if subfieldset.fields %}
<div>
<h4 class="mt-3">{{ subfieldset.title }}</h4>
{% for field in subfieldset.fields %}
{% with field=expert_form|get_field:field %}{{ field.as_field_group }}{% endwith %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if form %}
<input type="hidden"
name="active_form"
id="active-form-input"
value="custom">
{% endif %}
<div class="col-sm-12 d-flex justify-content-end">
<button class="btn btn-primary me-1 mb-1" type="submit">
{% if form_submit_label %}
{{ form_submit_label }}
{% else %}
{% translate "Save" %}
{% endif %}
</button>
{# browser form validation fails when there are fields missing/invalid that are hidden #}
<input class="btn btn-primary me-1 mb-1"
type="submit"
{% if form and expert_form %}formnovalidate{% endif %}
value="{% if form_submit_label %}{{ form_submit_label }}{% else %}{% translate "Save" %}{% endif %}" />
</div>
</form>
<script defer src="{% static 'js/advanced-fields.js' %}"></script>
<script defer src="{% static 'js/bootstrap-tabs.js' %}"></script>
{% if form and not hide_expert_mode %}
<script defer src="{% static 'js/expert-mode.js' %}"></script>
{% endif %}

View file

@ -1,3 +1,5 @@
from contextlib import suppress
from django import template
register = template.Library()
@ -5,4 +7,5 @@ register = template.Library()
@register.filter
def get_field(form, field_name):
return form[field_name]
with suppress(KeyError):
return form[field_name]

View file

@ -66,14 +66,33 @@ class ServiceDetailView(OrganizationViewMixin, DetailView):
def get_queryset(self):
return self.request.organization.get_visible_services()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
offerings = context["service"].offerings.all()
@cached_property
def visible_offerings(self):
offerings = self.object.offerings.all()
if self.request.organization.limit_cloudproviders.exists():
offerings = offerings.filter(
provider__in=self.request.organization.limit_cloudproviders.all()
)
context["visible_offerings"] = offerings.select_related("provider")
return offerings
def get(self, request, *args, **kwargs):
self.object = self.get_object()
# If there's exactly one offering, skip provider selection and go directly to it
if self.visible_offerings.count() == 1:
offering = self.visible_offerings.first()
return redirect(
"frontend:organization.offering",
organization=self.request.organization.slug,
slug=self.object.slug,
pk=offering.pk,
)
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["visible_offerings"] = self.visible_offerings.select_related("provider")
return context
@ -123,7 +142,9 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
def context_object(self):
if self.request.method == "POST":
return ControlPlaneCRD.objects.filter(
pk=self.request.POST.get("context"),
pk=self.request.POST.get(
"expert-context", self.request.POST.get("custom-context")
),
# Make sure we dont use a malicious ID
control_plane__in=self.planes,
).first()
@ -131,28 +152,52 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
control_plane=self.selected_plane, service_offering=self.object
).first()
def get_instance_form(self):
if not self.context_object or not self.context_object.model_form_class:
return None
initial = {
"organization": self.request.organization,
"context": self.context_object,
def get_instance_form_kwargs(self, ignore_data=False):
return {
"initial": {
"organization": self.request.organization,
"context": self.context_object,
},
"prefix": "expert",
"data": (
self.request.POST
if (self.request.method == "POST" and not ignore_data)
else None
),
}
# Pre-populate FQDN field if it exists and control plane has wildcard DNS
form_class = self.context_object.model_form_class
def get_instance_form(self, ignore_data=False):
if (
"spec.parameters.service.fqdn" in form_class.base_fields
and self.context_object.control_plane.wildcard_dns
not self.context_object
or not self.context_object.model_form_class
or self.hide_expert_mode
):
# Generate initial FQDN: instancename-namespace.wildcard_dns
# We'll set a placeholder that JavaScript will replace dynamically
initial["spec.parameters.service.fqdn"] = ""
return
return form_class(
data=self.request.POST if self.request.method == "POST" else None,
initial=initial,
return self.context_object.model_form_class(
**self.get_instance_form_kwargs(ignore_data=ignore_data)
)
def get_custom_instance_form(self, ignore_data=False):
if not self.context_object or not self.context_object.custom_model_form_class:
return
kwargs = self.get_instance_form_kwargs(ignore_data=ignore_data)
kwargs["prefix"] = "custom"
return self.context_object.custom_model_form_class(**kwargs)
@property
def is_custom_form(self):
# Note: "custom form" = user-friendly, subset of fields
# vs "expert form" = auto-generated (all technical fields)
return self.request.POST.get("active_form", "expert") == "custom"
@cached_property
def hide_expert_mode(self):
return (
self.context_object
and self.context_object.service_definition
and self.context_object.service_definition.form_config
and self.context_object.service_definition.hide_expert_mode
)
def get_context_data(self, **kwargs):
@ -160,8 +205,20 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
context["select_form"] = self.select_form
context["has_control_planes"] = self.planes.exists()
context["selected_plane"] = self.selected_plane
context["service_form"] = self.get_instance_form()
# Pass data for dynamic FQDN generation
context["hide_expert_mode"] = self.hide_expert_mode
if self.request.method == "POST":
if self.is_custom_form:
context["service_form"] = self.get_instance_form(ignore_data=True)
context["custom_service_form"] = self.get_custom_instance_form()
else:
context["service_form"] = self.get_instance_form()
context["custom_service_form"] = self.get_custom_instance_form(
ignore_data=True
)
else:
context["service_form"] = self.get_instance_form()
context["custom_service_form"] = self.get_custom_instance_form()
if self.selected_plane and self.selected_plane.wildcard_dns:
context["wildcard_dns"] = self.selected_plane.wildcard_dns
context["organization_namespace"] = self.request.organization.namespace
@ -175,7 +232,10 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
context["form_error"] = True
return self.render_to_response(context)
form = self.get_instance_form()
if self.is_custom_form:
form = self.get_custom_instance_form()
else:
form = self.get_instance_form()
if not form: # Should not happen if context_object is valid, but as a safeguard
messages.error(
self.request,
@ -203,8 +263,6 @@ class ServiceOfferingDetailView(OrganizationViewMixin, HtmxViewMixin, DetailView
)
form.add_error(None, error_message)
# If the form is not valid or if the service creation failed, we render it again
context["service_form"] = form
return self.render_to_response(context)
@ -393,11 +451,88 @@ class ServiceInstanceUpdateView(
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["instance"] = self.object.spec_object
kwargs["prefix"] = "expert"
return kwargs
def get_form(self, *args, ignore_data=False, **kwargs):
if self.hide_expert_mode:
return
if not ignore_data:
return super().get_form(*args, **kwargs)
cls = self.get_form_class()
kwargs = self.get_form_kwargs()
if ignore_data:
kwargs.pop("data", None)
return cls(**kwargs)
def get_custom_form(self, ignore_data=False):
cls = self.object.context.custom_model_form_class
if not cls:
return
kwargs = self.get_form_kwargs()
kwargs["prefix"] = "custom"
if ignore_data:
kwargs.pop("data", None)
return cls(**kwargs)
@property
def is_custom_form(self):
# Note: "custom form" = user-friendly, subset of fields
# vs "expert form" = auto-generated (all technical fields)
return self.request.POST.get("active_form", "expert") == "custom"
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if self.is_custom_form:
form = self.get_custom_form()
else:
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
return self.form_invalid(form)
@cached_property
def hide_expert_mode(self):
return (
self.object
and self.object.context
and self.object.context.service_definition
and self.object.context.service_definition.form_config
and self.object.context.service_definition.hide_expert_mode
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["hide_expert_mode"] = self.hide_expert_mode
if self.request.method == "POST":
if self.is_custom_form:
context["custom_form"] = self.get_custom_form()
context["form"] = self.get_form(ignore_data=True)
else:
context["custom_form"] = self.get_custom_form(ignore_data=True)
else:
context["custom_form"] = self.get_custom_form()
return context
def _deep_merge(self, base, update):
for key, value in update.items():
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
self._deep_merge(base[key], value)
else:
base[key] = value
return base
def form_valid(self, form):
try:
spec_data = form.get_nested_data().get("spec")
form_data = form.get_nested_data()
spec_data = form_data.get("spec")
if self.is_custom_form:
current_spec = dict(self.object.spec) if self.object.spec else {}
spec_data = self._deep_merge(current_spec, spec_data)
self.object.update_spec(spec_data=spec_data, updated_by=self.request.user)
messages.success(
self.request,

View file

@ -1,11 +1,10 @@
from django.conf import settings
from django.contrib import messages
from django.shortcuts import redirect
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.views.generic import FormView
from servala.core.odoo import CLIENT
from servala.core.odoo import create_helpdesk_ticket
from servala.frontend.forms.support import SupportForm
from servala.frontend.views.mixins import OrganizationViewMixin
@ -24,21 +23,16 @@ class SupportView(OrganizationViewMixin, FormView):
if not partner_id:
raise Exception("Could not get or create Odoo contact for user")
ticket_data = {
"name": f"Servala Support - Organization {organization.name}",
"team_id": settings.ODOO["HELPDESK_TEAM_ID"],
"partner_id": partner_id,
"description": message,
}
# All orgs should have a sale order ID, but legacy ones might not have it.
# Also, we want to be very sure that support requests work, especially for
# organizations where something in the creation process may have gone wrong,
# so if the ID does not exist, we omit it entirely.
if organization.odoo_sale_order_id:
ticket_data["sale_order_id"] = organization.odoo_sale_order_id
CLIENT.execute("helpdesk.ticket", "create", [ticket_data])
create_helpdesk_ticket(
title=f"Servala Support - Organization {organization.name}",
description=message,
partner_id=partner_id,
sale_order_id=organization.odoo_sale_order_id or None,
)
messages.success(
self.request,
_(

View file

@ -8,6 +8,7 @@ overrides/adds settings specific to testing.
from servala.settings import * # noqa: F403, F401
SECRET_KEY = "test-secret-key-for-testing-only-do-not-use-in-production"
SALT_KEY = SECRET_KEY
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]

View file

@ -237,42 +237,42 @@ a.btn-keycloak {
flex-grow: 1;
}
/* CRD Form mandatory field styling */
.crd-form .form-group.mandatory .form-label {
/* Expert CRD Form mandatory field styling */
.expert-crd-form .form-group.mandatory .form-label {
font-weight: bold;
position: relative;
}
.crd-form .form-group.mandatory .form-label::after {
.expert-crd-form .form-group.mandatory .form-label::after {
content: " *";
color: #dc3545;
font-weight: bold;
}
.crd-form .form-group.mandatory {
.expert-crd-form .form-group.mandatory {
border-left: 3px solid #dc3545;
padding-left: 10px;
background-color: rgba(220, 53, 69, 0.05);
border-radius: 3px;
}
.crd-form .nav-tabs .nav-link .mandatory-indicator {
.expert-crd-form .nav-tabs .nav-link .mandatory-indicator {
color: #dc3545;
font-weight: bold;
font-size: 1.1em;
margin-left: 4px;
}
html[data-bs-theme="dark"] .crd-form .form-group.mandatory {
html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory {
background-color: rgba(220, 53, 69, 0.1);
border-left-color: #ff6b6b;
}
html[data-bs-theme="dark"] .crd-form .form-group.mandatory .form-label::after {
html[data-bs-theme="dark"] .expert-crd-form .form-group.mandatory .form-label::after {
color: #ff6b6b;
}
html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator {
html[data-bs-theme="dark"] .expert-crd-form .nav-tabs .nav-link .mandatory-indicator {
color: #ff6b6b;
}
@ -306,33 +306,6 @@ html[data-bs-theme="dark"] .crd-form .nav-tabs .nav-link .mandatory-indicator {
margin-left: auto !important
}
/* Advanced fields tab flash animation */
@keyframes tab-pulse {
0%, 100% {
background-color: transparent;
box-shadow: none;
}
50% {
background-color: var(--brand-light);
box-shadow: 0 0 10px rgba(154, 99, 236, 0.3);
}
}
html[data-bs-theme="dark"] @keyframes tab-pulse {
0%, 100% {
background-color: transparent;
box-shadow: none;
}
50% {
background-color: rgba(154, 99, 236, 0.2);
box-shadow: 0 0 10px rgba(154, 99, 236, 0.4);
}
}
.nav-tabs .nav-link.tab-flash {
animation: tab-pulse 1s ease-in-out 2;
}
.beta-banner {
background: linear-gradient(135deg, var(--bs-primary) 0%, var(--brand-mid) 100%);
color: white;

View file

@ -1,83 +0,0 @@
/**
* Advanced Fields Toggle
* Handles showing/hiding advanced fields in CRD forms
*/
(function() {
'use strict';
function flashTabsWithAdvancedFields() {
const advancedGroups = document.querySelectorAll('.advanced-field-group');
const tabsToFlash = new Set();
advancedGroups.forEach(function(group) {
const tabPane = group.closest('.tab-pane');
if (tabPane) {
const tabId = tabPane.getAttribute('id');
if (tabId) {
const tabButton = document.querySelector(`[data-bs-target="#${tabId}"]`);
if (tabButton && !tabButton.classList.contains('active')) {
tabsToFlash.add(tabButton);
}
}
}
});
tabsToFlash.forEach(function(tab) {
tab.classList.add('tab-flash');
setTimeout(function() {
tab.classList.remove('tab-flash');
}, 2000);
});
}
function initializeAdvancedFields() {
const advancedInputs = document.querySelectorAll('[data-advanced="true"]');
if (advancedInputs.length === 0) {
return;
}
advancedInputs.forEach(function(input) {
const formGroup = input.closest('.form-group, .mb-3, .col-12, .col-md-6');
if (formGroup) {
formGroup.classList.add('advanced-field-group', 'collapse');
}
});
const toggleButton = document.getElementById('advanced-toggle');
if (toggleButton) {
let isExpanded = false;
document.querySelectorAll('.advanced-field-group').forEach(function(group) {
group.addEventListener('shown.bs.collapse', function() {
toggleButton.innerHTML = '<i class="bi bi-gear-fill"></i> Hide Advanced Options';
if (!isExpanded) {
isExpanded = true;
setTimeout(flashTabsWithAdvancedFields, 100);
}
});
group.addEventListener('hidden.bs.collapse', function() {
const anyVisible = Array.from(document.querySelectorAll('.advanced-field-group')).some(
g => g.classList.contains('show')
);
if (!anyVisible) {
toggleButton.innerHTML = '<i class="bi bi-gear"></i> Show Advanced Options';
isExpanded = false;
}
});
});
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeAdvancedFields);
} else {
initializeAdvancedFields();
}
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'service-form' || event.detail.target.closest('.crd-form')) {
setTimeout(initializeAdvancedFields, 100);
}
});
})();

30
src/servala/static/js/bootstrap-tabs.js vendored Normal file
View file

@ -0,0 +1,30 @@
// Bootstrap 5 automatically initializes tabs with data-bs-toggle="tab"
// but we need to ensure they work after HTMX swaps
(function() {
'use strict';
const initBootstrapTabs = () => {
const customTabList = document.querySelectorAll('#myTab button[data-bs-toggle="tab"]');
customTabList.forEach(function(tabButton) {
new bootstrap.Tab(tabButton);
});
const expertTabList = document.querySelectorAll('#expertTab button[data-bs-toggle="tab"]');
expertTabList.forEach(function(tabButton) {
new bootstrap.Tab(tabButton);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBootstrapTabs);
} else {
initBootstrapTabs();
}
document.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'service-form' ||
event.detail.target.classList.contains('crd-form')) {
initBootstrapTabs();
}
});
})();

View file

@ -0,0 +1,49 @@
(function() {
'use strict';
let isExpertMode = false;
function initExpertMode() {
const toggleButton = document.getElementById('expert-mode-toggle');
if (!toggleButton) return;
const customFormContainer = document.getElementById('custom-form-container');
const expertFormContainer = document.getElementById('expert-form-container');
if (!customFormContainer || !expertFormContainer) {
console.warn('Expert mode containers not found');
return;
}
toggleButton.addEventListener('click', function(e) {
e.preventDefault();
isExpertMode = !isExpertMode;
const activeFormInput = document.getElementById('active-form-input');
if (isExpertMode) {
customFormContainer.style.display = 'none';
expertFormContainer.style.display = 'block';
toggleButton.textContent = 'Show Simplified Form';
if (activeFormInput) activeFormInput.value = 'expert';
} else {
customFormContainer.style.display = 'block';
expertFormContainer.style.display = 'none';
toggleButton.textContent = 'Show Expert Mode';
if (activeFormInput) activeFormInput.value = 'custom';
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initExpertMode);
} else {
initExpertMode();
}
document.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'service-form' || event.detail.target.classList.contains('crd-form')) {
initExpertMode();
}
});
})();

View file

@ -1,7 +1,21 @@
const initializeFqdnGeneration = () => {
const nameField = document.querySelector('input#id_name');
const fqdnField = document.querySelector('label[for="id_spec.parameters.service.fqdn"] + div input.array-item-input');
const initializeFqdnGeneration = (prefix) => {
const nameField = document.querySelector(`input#id_${prefix}-name`);
if (!nameField) return
// Try to find array input first (DynamicArrayWidget), then fallback to regular text input
const fqdnFieldContainer = document.getElementById(`${prefix}-spec.parameters.service.fqdn_container`)
let fqdnField = null;
let isArrayField = true;
if (fqdnFieldContainer) {
let fqdnField = fqdnFieldContainer.querySelector('input.array-item-input');
} else {
fqdnField = document.getElementById(`id_${prefix}-spec.parameters.service.fqdn`);
isArrayField = false;
}
if (!fqdnField) return
if (nameField && fqdnField) {
const generateFqdn = (instanceName) => {
@ -9,38 +23,40 @@ const initializeFqdnGeneration = () => {
return `${instanceName}-${fqdnConfig.namespace}.${fqdnConfig.wildcardDns}`;
}
const newNameField = nameField.cloneNode(true);
nameField.parentNode.replaceChild(newNameField, nameField);
const newFqdnField = fqdnField.cloneNode(true);
fqdnField.parentNode.replaceChild(newFqdnField, fqdnField);
newNameField.addEventListener('input', function() {
if (!newFqdnField.dataset.manuallyEdited) {
newFqdnField.value = generateFqdn(this.value);
const container = newFqdnField.closest('.dynamic-array-widget');
if (container && window.updateHiddenInput) {
window.updateHiddenInput(container);
nameField.addEventListener('input', function() {
if (!fqdnField.dataset.manuallyEdited) {
fqdnField.value = generateFqdn(this.value);
if (isArrayField) {
// Update hidden input for array fields
const container = fqdnField.closest('.dynamic-array-widget');
if (container && window.updateHiddenInput) {
window.updateHiddenInput(container);
}
}
}
});
newFqdnField.addEventListener('input', function() {
fqdnField.addEventListener('input', function() {
this.dataset.manuallyEdited = 'true';
});
if (newNameField.value && !newFqdnField.value) {
newFqdnField.value = generateFqdn(newNameField.value);
const container = newFqdnField.closest('.dynamic-array-widget');
if (container && window.updateHiddenInput) {
window.updateHiddenInput(container);
if (nameField.value && !fqdnField.value) {
fqdnField.value = generateFqdn(nameField.value);
if (isArrayField) {
// Update hidden input for array fields
const container = fqdnField.closest('.dynamic-array-widget');
if (container && window.updateHiddenInput) {
window.updateHiddenInput(container);
}
}
}
}
}
document.addEventListener('DOMContentLoaded', initializeFqdnGeneration);
document.addEventListener('DOMContentLoaded', () => {initializeFqdnGeneration("custom"), initializeFqdnGeneration("expert")});
document.body.addEventListener('htmx:afterSwap', function(event) {
if (event.detail.target.id === 'service-form') {
initializeFqdnGeneration();
initializeFqdnGeneration("custom");
initializeFqdnGeneration("expert");
}
});

View file

@ -5,6 +5,12 @@ from django.core import mail
from django_scopes import scopes_disabled
from servala.core.models import Organization, OrganizationOrigin, User
from servala.core.models.service import (
ControlPlane,
ControlPlaneCRD,
ServiceDefinition,
ServiceInstance,
)
@pytest.fixture
@ -451,3 +457,290 @@ def test_organization_creation_with_context_only(
assert response.status_code == 201
org = Organization.objects.get(osb_guid="fallback-org-guid")
assert org is not None
@pytest.mark.django_db
def test_delete_offboarding_success(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
instance_id,
):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}"
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 200
assert response.content == b"{}"
@pytest.mark.django_db
def test_delete_missing_service_id(osb_client, test_service_offering, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}?plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "service_id is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_delete_missing_plan_id(osb_client, test_service, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}?service_id={test_service.osb_service_id}"
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "plan_id is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_delete_invalid_service_id(osb_client, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}?service_id=invalid&plan_id=invalid"
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "Unknown service_id: invalid" in response_data["error"]
@pytest.mark.django_db
def test_delete_invalid_plan_id(osb_client, test_service, instance_id):
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}"
f"?service_id={test_service.osb_service_id}&plan_id=invalid"
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert (
f"Unknown plan_id: invalid for service_id: {test_service.osb_service_id}"
in response_data["error"]
)
@pytest.mark.django_db
def test_patch_suspension_success(
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
instance_id,
):
payload = {
"service_id": test_service.osb_service_id,
"plan_id": test_service_offering.osb_plan_id,
"parameters": {
"users": [
{
"email": "user@example.com",
"full_name": "Test User",
"role": "owner",
}
]
},
}
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 200
assert response.content == b"{}"
@pytest.mark.django_db
def test_patch_missing_service_id(osb_client, test_service_offering, instance_id):
payload = {
"plan_id": test_service_offering.osb_plan_id,
"parameters": {"users": []},
}
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "service_id is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_patch_missing_plan_id(osb_client, test_service, instance_id):
payload = {
"service_id": test_service.osb_service_id,
"parameters": {"users": []},
}
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "plan_id is required but missing" in response_data["error"]
@pytest.mark.django_db
def test_patch_invalid_json(osb_client, instance_id):
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data="invalid json{",
content_type="application/json",
)
assert response.status_code == 400
response_data = json.loads(response.content)
assert "Invalid JSON in request body" in response_data["error"]
@pytest.mark.django_db
def test_delete_creates_ticket_with_admin_links(
mocker,
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
instance_id,
):
# Mock the create_helpdesk_ticket function
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_id}"
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 200
# Verify the ticket was created with admin URL
mock_create_ticket.assert_called_once()
call_kwargs = mock_create_ticket.call_args[1]
# Check that the description contains an admin URL
assert "admin/core/serviceoffering" in call_kwargs["description"]
assert f"/{test_service_offering.pk}/" in call_kwargs["description"]
assert (
call_kwargs["title"]
== f"Exoscale OSB Offboard - {test_service.name} - {instance_id}"
)
@pytest.mark.django_db
def test_patch_creates_ticket_with_user_admin_links(
mocker,
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
instance_id,
org_owner,
):
# Mock the create_helpdesk_ticket function
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
payload = {
"service_id": test_service.osb_service_id,
"plan_id": test_service_offering.osb_plan_id,
"parameters": {
"users": [
{
"email": org_owner.email,
"full_name": "Test User",
"role": "owner",
}
]
},
}
response = osb_client.patch(
f"/api/osb/v2/service_instances/{instance_id}",
data=json.dumps(payload),
content_type="application/json",
)
assert response.status_code == 200
# Verify the ticket was created with admin URLs
mock_create_ticket.assert_called_once()
call_kwargs = mock_create_ticket.call_args[1]
# Check that the description contains admin URLs
assert "admin/core/serviceoffering" in call_kwargs["description"]
assert "admin/core/user" in call_kwargs["description"]
assert f"/{org_owner.pk}/" in call_kwargs["description"]
assert (
call_kwargs["title"]
== f"Exoscale OSB Suspend - {test_service.name} - {instance_id}"
)
@pytest.mark.django_db
def test_ticket_includes_organization_and_instance_when_found(
mocker,
mock_odoo_success,
osb_client,
test_service,
test_service_offering,
organization,
):
# Mock the create_helpdesk_ticket function
mock_create_ticket = mocker.patch("servala.api.views.create_helpdesk_ticket")
service_definition = ServiceDefinition.objects.create(
name="Test Definition",
service=test_service,
api_definition={"group": "test.example.com", "version": "v1", "kind": "Test"},
)
control_plane = ControlPlane.objects.create(
name="Test Control Plane",
cloud_provider=test_service_offering.provider,
api_credentials={
"certificate-authority-data": "test",
"server": "https://test",
"token": "test",
},
)
crd = ControlPlaneCRD.objects.create(
service_offering=test_service_offering,
control_plane=control_plane,
service_definition=service_definition,
)
instance_name = "test-instance-123"
service_instance = ServiceInstance.objects.create(
name=instance_name,
organization=organization,
context=crd,
)
response = osb_client.delete(
f"/api/osb/v2/service_instances/{instance_name}"
f"?service_id={test_service.osb_service_id}&plan_id={test_service_offering.osb_plan_id}"
)
assert response.status_code == 200
# Verify the ticket was created with all admin URLs
mock_create_ticket.assert_called_once()
call_kwargs = mock_create_ticket.call_args[1]
# Check organization is included
assert f"Organization: {organization.name}" in call_kwargs["description"]
assert "admin/core/organization" in call_kwargs["description"]
assert f"/{organization.pk}/" in call_kwargs["description"]
# Check instance is included
assert f"Instance: {service_instance.name}" in call_kwargs["description"]
assert "admin/core/serviceinstance" in call_kwargs["description"]
assert f"/{service_instance.pk}/" in call_kwargs["description"]

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,7 @@
import pytest
from servala.core.models.service import CloudProvider, ServiceOffering
@pytest.mark.parametrize(
"url,redirect",
@ -45,3 +47,103 @@ def test_organization_linked_in_sidebar(
assert response.status_code == 200
assert organization.name in response.content.decode()
assert other_organization.name not in response.content.decode()
@pytest.mark.django_db
def test_service_detail_redirects_with_single_offering(
client, org_owner, organization, test_service, test_service_offering
):
client.force_login(org_owner)
url = f"/org/{organization.slug}/services/{test_service.slug}/"
response = client.get(url)
assert response.status_code == 302
expected_url = f"/org/{organization.slug}/services/{test_service.slug}/offering/{test_service_offering.pk}/"
assert response.url == expected_url
@pytest.mark.django_db
def test_service_detail_shows_multiple_offerings(
client, org_owner, organization, test_service, test_service_offering
):
second_provider = CloudProvider.objects.create(
name="AWS", description="Amazon Web Services"
)
second_offering = ServiceOffering.objects.create(
service=test_service,
provider=second_provider,
description="Redis on AWS",
osb_plan_id="test-plan-456",
)
client.force_login(org_owner)
url = f"/org/{organization.slug}/services/{test_service.slug}/"
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert test_service_offering.provider.name in content
assert second_offering.provider.name in content
assert "Create Instance" in content
@pytest.mark.django_db
def test_service_detail_respects_cloud_provider_restrictions(
client, org_owner, organization, test_service, test_service_offering
):
second_provider = CloudProvider.objects.create(
name="AWS", description="Amazon Web Services"
)
ServiceOffering.objects.create(
service=test_service,
provider=second_provider,
description="Redis on AWS",
osb_plan_id="test-plan-456",
)
organization.origin.limit_cloudproviders.add(test_service_offering.provider)
client.force_login(org_owner)
url = f"/org/{organization.slug}/services/{test_service.slug}/"
response = client.get(url)
assert response.status_code == 302
expected_url = f"/org/{organization.slug}/services/{test_service.slug}/offering/{test_service_offering.pk}/"
assert response.url == expected_url
@pytest.mark.django_db
def test_service_detail_no_redirect_with_restricted_multiple_offerings(
client, org_owner, organization, test_service, test_service_offering
):
second_provider = CloudProvider.objects.create(
name="AWS", description="Amazon Web Services"
)
second_offering = ServiceOffering.objects.create(
service=test_service,
provider=second_provider,
description="Redis on AWS",
osb_plan_id="test-plan-456",
)
third_provider = CloudProvider.objects.create(
name="Azure", description="Microsoft Azure"
)
third_offering = ServiceOffering.objects.create(
service=test_service,
provider=third_provider,
description="Redis on Azure",
osb_plan_id="test-plan-789",
)
organization.origin.limit_cloudproviders.add(
test_service_offering.provider, second_provider
)
client.force_login(org_owner)
url = f"/org/{organization.slug}/services/{test_service.slug}/"
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert test_service_offering.provider.name in content
assert second_offering.provider.name in content
assert third_offering.provider.name not in content

486
uv.lock generated
View file

@ -1,10 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
]
requires-python = ">=3.14.0"
[[package]]
name = "argon2-cffi"
@ -51,11 +47,11 @@ wheels = [
[[package]]
name = "asgiref"
version = "3.10.0"
version = "3.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" }
sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" },
{ url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
]
[[package]]
@ -69,7 +65,7 @@ wheels = [
[[package]]
name = "black"
version = "25.9.0"
version = "25.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@ -79,41 +75,41 @@ dependencies = [
{ name = "platformdirs" },
{ name = "pytokens" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393, upload-time = "2025-09-19T00:27:37.758Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/99/3acfea65f5e79f45472c45f87ec13037b506522719cd9d4ac86484ff51ac/black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175", size = 1742165, upload-time = "2025-09-19T00:34:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/3a/18/799285282c8236a79f25d590f0222dbd6850e14b060dfaa3e720241fd772/black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f", size = 1581259, upload-time = "2025-09-19T00:32:49.685Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ce/883ec4b6303acdeca93ee06b7622f1fa383c6b3765294824165d49b1a86b/black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831", size = 1655583, upload-time = "2025-09-19T00:30:44.505Z" },
{ url = "https://files.pythonhosted.org/packages/21/17/5c253aa80a0639ccc427a5c7144534b661505ae2b5a10b77ebe13fa25334/black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357", size = 1343428, upload-time = "2025-09-19T00:32:13.839Z" },
{ url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" },
{ url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" },
{ url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" },
{ url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" },
{ url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" },
{ url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" },
]
[[package]]
name = "boto3"
version = "1.40.59"
version = "1.42.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/f4/65f3a0a58a42abaa57cb42968535dc7c209232c2614d5ac1d8354b0bc0b7/boto3-1.40.59.tar.gz", hash = "sha256:b1a5a203511e594872b39a129365f02eb5846eea990629e8daf47a3c01e7fd49", size = 111577, upload-time = "2025-10-24T19:23:33.763Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/9b/eef5346ce3148bf4856318fe629e0fd7f6dd73ffd55ea08e316c967f8af0/boto3-1.42.0.tar.gz", hash = "sha256:9c67729a6112b7dced521ea70b0369fba138e89852b029a7876041cd1460c084", size = 112854, upload-time = "2025-12-01T02:31:09.157Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/61/25cbd486b03d4f786507584025a35ae74fa52fe2408fbf0318d91e3e96db/boto3-1.40.59-py3-none-any.whl", hash = "sha256:75752e7dc445131700a58926a50ca705794232f0f47d0e21edb59fbf1898db95", size = 139323, upload-time = "2025-10-24T19:23:31.824Z" },
{ url = "https://files.pythonhosted.org/packages/e6/2c/6c6ee5667426aee6629106b9e51668449fb34ec077655da82bf4b15d8890/boto3-1.42.0-py3-none-any.whl", hash = "sha256:af32b7f61dd6293cad728ec205bcb3611ab1bf7b7dbccfd0f2bd7b9c9af96039", size = 140617, upload-time = "2025-12-01T02:31:07.238Z" },
]
[[package]]
name = "botocore"
version = "1.40.59"
version = "1.41.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/0a/4abd361449e495bc6f0eb24dc14213c1468253a5be63cfcd3b6f9feca992/botocore-1.40.59.tar.gz", hash = "sha256:842a466d8735272a30fe5b7f97df559d9e211a18e412f62a17ed249fd62f85fe", size = 14472896, upload-time = "2025-10-24T19:23:22.467Z" }
sdist = { url = "https://files.pythonhosted.org/packages/03/04/8e8ca38631eeb499a1099dcc2a081faaea399f9d46080720540ff54ec609/botocore-1.41.6.tar.gz", hash = "sha256:08fe47e9b306f4436f5eaf6a02cb6d55c7745d13d2d093ce5d917d3ef3d3df75", size = 14770281, upload-time = "2025-12-01T02:30:54.286Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/34/72ba24f52b14669384ede828ea08927b444c52311e67e02d9cdc6f00b882/botocore-1.40.59-py3-none-any.whl", hash = "sha256:042dd844ca82155ca1ab9608b9bef36d517515c775d075f57b89257108ae843b", size = 14139459, upload-time = "2025-10-24T19:23:18.425Z" },
{ url = "https://files.pythonhosted.org/packages/ab/d4/587a71c599997b0f7aa842ea71604348f5a7d239cfff338292904f236983/botocore-1.41.6-py3-none-any.whl", hash = "sha256:963cc946e885acb941c96e7d343cb6507b479812ca22566ceb3e9410d0588de0", size = 14442076, upload-time = "2025-12-01T02:30:50.724Z" },
]
[[package]]
@ -133,20 +129,20 @@ wheels = [
[[package]]
name = "cachetools"
version = "6.2.1"
version = "6.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" },
{ url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
version = "2025.11.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
]
[[package]]
@ -158,18 +154,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
@ -200,22 +184,6 @@ version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
@ -237,14 +205,14 @@ wheels = [
[[package]]
name = "click"
version = "8.3.0"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
@ -258,63 +226,37 @@ wheels = [
[[package]]
name = "coverage"
version = "7.11.0"
version = "7.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" }
sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" },
{ url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" },
{ url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" },
{ url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" },
{ url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" },
{ url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" },
{ url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" },
{ url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" },
{ url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" },
{ url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" },
{ url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" },
{ url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" },
{ url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" },
{ url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" },
{ url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" },
{ url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" },
{ url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" },
{ url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" },
{ url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" },
{ url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" },
{ url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" },
{ url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" },
{ url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" },
{ url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" },
{ url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" },
{ url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" },
{ url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" },
{ url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" },
{ url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" },
{ url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" },
{ url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" },
{ url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" },
{ url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" },
{ url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" },
{ url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" },
{ url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" },
{ url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" },
{ url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" },
{ url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" },
{ url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" },
{ url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" },
{ url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" },
{ url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" },
{ url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" },
{ url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" },
{ url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" },
{ url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" },
{ url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" },
{ url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" },
{ url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" },
{ url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" },
{ url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" },
{ url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" },
{ url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" },
{ url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" },
{ url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" },
{ url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" },
{ url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" },
{ url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" },
{ url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" },
{ url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" },
{ url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" },
{ url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" },
{ url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" },
{ url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" },
{ url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" },
{ url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" },
{ url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" },
]
[[package]]
@ -389,27 +331,30 @@ wheels = [
[[package]]
name = "django"
version = "5.2.7"
version = "5.2.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/96/bd84e2bb997994de8bcda47ae4560991084e86536541d7214393880f01a8/django-5.2.7.tar.gz", hash = "sha256:e0f6f12e2551b1716a95a63a1366ca91bbcd7be059862c1b18f989b1da356cdd", size = 10865812, upload-time = "2025-10-01T14:22:12.081Z" }
sdist = { url = "https://files.pythonhosted.org/packages/05/a2/933dbbb3dd9990494960f6e64aca2af4c0745b63b7113f59a822df92329e/django-5.2.8.tar.gz", hash = "sha256:23254866a5bb9a2cfa6004e8b809ec6246eba4b58a7589bc2772f1bcc8456c7f", size = 10849032, upload-time = "2025-11-05T14:07:32.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
{ url = "https://files.pythonhosted.org/packages/5e/3d/a035a4ee9b1d4d4beee2ae6e8e12fe6dee5514b21f62504e22efcbd9fb46/django-5.2.8-py3-none-any.whl", hash = "sha256:37e687f7bd73ddf043e2b6b97cfe02fcbb11f2dbb3adccc6a2b18c6daa054d7f", size = 8289692, upload-time = "2025-11-05T14:07:28.761Z" },
]
[[package]]
name = "django-allauth"
version = "65.12.1"
version = "65.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/94/75d7f8c59e061d1b66a6d917b287817fe02d2671c9e6376a4ddfb3954989/django_allauth-65.12.1.tar.gz", hash = "sha256:662666ff2d5c71766f66b1629ac7345c30796813221184e13e11ed7460940c6a", size = 1967971, upload-time = "2025-10-16T16:39:58.342Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/42a048ba1dedbb6b553f5376a6126b1c753c10c70d1edab8f94c560c8066/django_allauth-65.13.1.tar.gz", hash = "sha256:2af0d07812f8c1a8e3732feaabe6a9db5ecf3fad6b45b6a0f7fd825f656c5a15", size = 1983857, upload-time = "2025-11-20T16:34:40.811Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/98/9d44ae1468abfdb521d651fb67f914165c7812dfdd97be16190c9b1cc246/django_allauth-65.13.1-py3-none-any.whl", hash = "sha256:2887294beedfd108b4b52ebd182e0ed373deaeb927fc5a22f77bbde3174704a6", size = 1787349, upload-time = "2025-11-20T16:34:37.354Z" },
]
[[package]]
name = "django-auditlog"
@ -426,15 +371,15 @@ wheels = [
[[package]]
name = "django-fernet-encrypted-fields"
version = "0.3.0"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/b8/b6725f1207693ba9e76223abf87eb9e8de5114cccad8ddd1bce29a195273/django-fernet-encrypted-fields-0.3.0.tar.gz", hash = "sha256:38031bdaf1724a6e885ee137cc66a2bd7dc3726c438e189ea7e44799ec0ba9b3", size = 4021, upload-time = "2025-02-21T02:58:42.049Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/aa/529af3888215b8a660fc3897d6d63eaf1de9aa0699c633ca0ec483d4361c/django_fernet_encrypted_fields-0.3.1.tar.gz", hash = "sha256:5ed328c7f9cc7f2d452bb2e125f3ea2bea3563a259fa943e5a1c626175889a71", size = 5265, upload-time = "2025-11-10T08:39:57.398Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/8a/2c5d88cd540d83ceaa1cb3191ed35dfed0caacc6fe2ff5fe74c9ecc7776f/django_fernet_encrypted_fields-0.3.0-py3-none-any.whl", hash = "sha256:a17cca5bf3638ee44674e64f30792d5960b1d4d4b291ec478c27515fc4860612", size = 5400, upload-time = "2025-02-21T02:58:40.832Z" },
{ url = "https://files.pythonhosted.org/packages/52/7f/4e0b7ed8413fa58e7a77017342e8ab0e977d41cfc376ab9180ae75f216ec/django_fernet_encrypted_fields-0.3.1-py3-none-any.whl", hash = "sha256:3bd2abab02556dc6e15a58a61161ee6c5cdf45a50a8a52d9e035009eb54c6442", size = 5484, upload-time = "2025-11-10T08:39:55.866Z" },
]
[[package]]
@ -480,14 +425,14 @@ s3 = [
[[package]]
name = "django-template-partials"
version = "25.2"
version = "25.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/7f/9eca482fbfd42f2ae19fa77fa231c0d2ba6ec7caf0ced16926480e1d746b/django_template_partials-25.2.tar.gz", hash = "sha256:55044e4a12d5d3adbc02df0758eb08fcd4e3451203e02a819f9853451696aef6", size = 17787, upload-time = "2025-09-17T13:31:37.761Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/2e/957ee4a6ee0a7d46a18676ba8b01d762ef89d00b7769cc532853f9f989e1/django_template_partials-25.3.tar.gz", hash = "sha256:6d11f7bb049ce3032e6fe3331137b771e34239ce1af18c55ef6a9b667cf2ef36", size = 18052, upload-time = "2025-11-14T08:27:21.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/30/96a9d0e70efd00af9b9c011111b5eba48d17914e5b3d39516d4a7cc7e7fb/django_template_partials-25.2-py2.py3-none-any.whl", hash = "sha256:4c4f6569bd2d016281700a215c09ba4b7a4bbd2da95ce15d8c1ae76d36da44b4", size = 9621, upload-time = "2025-09-17T13:31:36.452Z" },
{ url = "https://files.pythonhosted.org/packages/9b/9d/48f8721e48b938ca2e2dde577986624543be6ff9bdccac20ccb747be4287/django_template_partials-25.3-py2.py3-none-any.whl", hash = "sha256:a19334934cf40e4e1218802a4ddfdf22b8f78cc5a0b8c75a18b97e6ea4f3c108", size = 9702, upload-time = "2025-11-14T08:27:20.243Z" },
]
[[package]]
@ -507,10 +452,6 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1", size = 47849, upload-time = "2024-12-24T13:06:36.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2", size = 354384, upload-time = "2024-12-24T13:06:20.809Z" },
{ url = "https://files.pythonhosted.org/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835", size = 322971, upload-time = "2024-12-24T13:06:22.185Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f", size = 410972, upload-time = "2024-12-24T13:06:24.077Z" },
{ url = "https://files.pythonhosted.org/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4", size = 362053, upload-time = "2024-12-24T13:06:25.432Z" },
{ url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" },
]
@ -548,40 +489,40 @@ wheels = [
[[package]]
name = "flake8-bugbear"
version = "25.10.21"
version = "25.11.29"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "flake8" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/54/0f6e431adbc67fd420540e386cb20b57e73e8aeb393f0ae2311e91b4548f/flake8_bugbear-25.10.21.tar.gz", hash = "sha256:2876afcaed8bfb3464cf33e3ec42cc3bec0a004165b84400dc3392b0547c2714", size = 83080, upload-time = "2025-10-22T01:27:03.63Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ec/20/2a996e2fca7810bd1b031901d65fc4292630895afcb946ebd00568bdc669/flake8_bugbear-25.11.29.tar.gz", hash = "sha256:b5d06710f3d26e595541ad303ad4d5cb52578bd4bccbb2c2c0b2c72e243dafc8", size = 84896, upload-time = "2025-11-29T20:51:57.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/0e/8ba976f7d477cad69cc7af08dc7b0163181a5e19a82fe721f954e369c067/flake8_bugbear-25.10.21-py3-none-any.whl", hash = "sha256:f1c5654f9d9d3e62e90da1f0335551fdbc565c51749713177dbcfb9edb105405", size = 37257, upload-time = "2025-10-22T01:27:02.105Z" },
{ url = "https://files.pythonhosted.org/packages/0d/42/c18f199780d99a6f6a64c4a36f4ad28a445d9e11968a6025b21d0c8b6802/flake8_bugbear-25.11.29-py3-none-any.whl", hash = "sha256:9bf15e2970e736d2340da4c0a70493db964061c9c38f708cfe1f7b2d87392298", size = 37861, upload-time = "2025-11-29T20:51:56.439Z" },
]
[[package]]
name = "flake8-pyproject"
version = "1.2.3"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flake8" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/1d/635e86f9f3a96b7ea9e9f19b5efe17a987e765c39ca496e4a893bb999112/flake8_pyproject-1.2.3-py3-none-any.whl", hash = "sha256:6249fe53545205af5e76837644dc80b4c10037e73a0e5db87ff562d75fb5bd4a", size = 4756, upload-time = "2023-03-21T20:51:38.911Z" },
{ url = "https://files.pythonhosted.org/packages/85/6a/cdee9ff7f2b7c6ddc219fd95b7c70c0a3d9f0367a506e9793eedfc72e337/flake8_pyproject-1.2.4-py3-none-any.whl", hash = "sha256:ea34c057f9a9329c76d98723bb2bb498cc6ba8ff9872c4d19932d48c91249a77", size = 5694, upload-time = "2025-11-28T21:40:01.309Z" },
]
[[package]]
name = "google-auth"
version = "2.41.1"
version = "2.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachetools" },
{ name = "pyasn1-modules" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/af/5129ce5b2f9688d2fa49b463e544972a7c82b0fdb50980dafee92e121d9f/google_auth-2.41.1.tar.gz", hash = "sha256:b76b7b1f9e61f0cb7e88870d14f6a94aeef248959ef6992670efee37709cbfd2", size = 292284, upload-time = "2025-09-30T22:51:26.363Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/ef/66d14cf0e01b08d2d51ffc3c20410c4e134a1548fc246a6081eae585a4fe/google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483", size = 296359, upload-time = "2025-11-06T00:13:36.587Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/a4/7319a2a8add4cc352be9e3efeff5e2aacee917c85ca2fa1647e29089983c/google_auth-2.41.1-py2.py3-none-any.whl", hash = "sha256:754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d", size = 221302, upload-time = "2025-09-30T22:51:24.212Z" },
{ url = "https://files.pythonhosted.org/packages/6f/d1/385110a9ae86d91cc14c5282c61fe9f4dc41c0b9f7d423c6ad77038c4448/google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16", size = 223114, upload-time = "2025-11-06T00:13:35.209Z" },
]
[[package]]
@ -750,31 +691,6 @@ version = "12.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
@ -826,22 +742,15 @@ version = "2.9.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
]
@ -914,7 +823,7 @@ wheels = [
[[package]]
name = "pytest"
version = "8.4.2"
version = "9.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@ -923,9 +832,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
{ url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" },
]
[[package]]
@ -980,11 +889,11 @@ wheels = [
[[package]]
name = "pytokens"
version = "0.2.0"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/c2/dbadcdddb412a267585459142bfd7cc241e6276db69339353ae6e241ab2b/pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43", size = 15368, upload-time = "2025-10-15T08:02:42.738Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/5a/c269ea6b348b6f2c32686635df89f32dbe05df1088dd4579302a6f8f99af/pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8", size = 12038, upload-time = "2025-10-15T08:02:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" },
]
[[package]]
@ -993,16 +902,6 @@ version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
@ -1038,66 +937,38 @@ wheels = [
[[package]]
name = "regex"
version = "2025.10.23"
version = "2025.11.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/c8/1d2160d36b11fbe0a61acb7c3c81ab032d9ec8ad888ac9e0a61b85ab99dd/regex-2025.10.23.tar.gz", hash = "sha256:8cbaf8ceb88f96ae2356d01b9adf5e6306fa42fa6f7eab6b97794e37c959ac26", size = 401266, upload-time = "2025-10-21T15:58:20.23Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/a9/546676f25e573a4cf00fe8e119b78a37b6a8fe2dc95cda877b30889c9c45/regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01", size = 414669, upload-time = "2025-11-03T21:34:22.089Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/c6/195a6217a43719d5a6a12cc192a22d12c40290cecfa577f00f4fb822f07d/regex-2025.10.23-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b7690f95404a1293923a296981fd943cca12c31a41af9c21ba3edd06398fc193", size = 488956, upload-time = "2025-10-21T15:55:42.887Z" },
{ url = "https://files.pythonhosted.org/packages/4c/93/181070cd1aa2fa541ff2d3afcf763ceecd4937b34c615fa92765020a6c90/regex-2025.10.23-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a32d77aeaea58a13230100dd8797ac1a84c457f3af2fdf0d81ea689d5a9105b", size = 290997, upload-time = "2025-10-21T15:55:44.53Z" },
{ url = "https://files.pythonhosted.org/packages/b6/c5/9d37fbe3a40ed8dda78c23e1263002497540c0d1522ed75482ef6c2000f0/regex-2025.10.23-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b24b29402f264f70a3c81f45974323b41764ff7159655360543b7cabb73e7d2f", size = 288686, upload-time = "2025-10-21T15:55:46.186Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e7/db610ff9f10c2921f9b6ac0c8d8be4681b28ddd40fc0549429366967e61f/regex-2025.10.23-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563824a08c7c03d96856d84b46fdb3bbb7cfbdf79da7ef68725cda2ce169c72a", size = 798466, upload-time = "2025-10-21T15:55:48.24Z" },
{ url = "https://files.pythonhosted.org/packages/90/10/aab883e1fa7fe2feb15ac663026e70ca0ae1411efa0c7a4a0342d9545015/regex-2025.10.23-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0ec8bdd88d2e2659c3518087ee34b37e20bd169419ffead4240a7004e8ed03b", size = 863996, upload-time = "2025-10-21T15:55:50.478Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b0/8f686dd97a51f3b37d0238cd00a6d0f9ccabe701f05b56de1918571d0d61/regex-2025.10.23-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b577601bfe1d33913fcd9276d7607bbac827c4798d9e14d04bf37d417a6c41cb", size = 912145, upload-time = "2025-10-21T15:55:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ca/639f8cd5b08797bca38fc5e7e07f76641a428cf8c7fca05894caf045aa32/regex-2025.10.23-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c9f2c68ac6cb3de94eea08a437a75eaa2bd33f9e97c84836ca0b610a5804368", size = 803370, upload-time = "2025-10-21T15:55:53.944Z" },
{ url = "https://files.pythonhosted.org/packages/0d/1e/a40725bb76959eddf8abc42a967bed6f4851b39f5ac4f20e9794d7832aa5/regex-2025.10.23-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:89f8b9ea3830c79468e26b0e21c3585f69f105157c2154a36f6b7839f8afb351", size = 787767, upload-time = "2025-10-21T15:55:56.004Z" },
{ url = "https://files.pythonhosted.org/packages/3d/d8/8ee9858062936b0f99656dce390aa667c6e7fb0c357b1b9bf76fb5e2e708/regex-2025.10.23-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:98fd84c4e4ea185b3bb5bf065261ab45867d8875032f358a435647285c722673", size = 858335, upload-time = "2025-10-21T15:55:58.185Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0a/ed5faaa63fa8e3064ab670e08061fbf09e3a10235b19630cf0cbb9e48c0a/regex-2025.10.23-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1e11d3e5887b8b096f96b4154dfb902f29c723a9556639586cd140e77e28b313", size = 850402, upload-time = "2025-10-21T15:56:00.023Z" },
{ url = "https://files.pythonhosted.org/packages/79/14/d05f617342f4b2b4a23561da500ca2beab062bfcc408d60680e77ecaf04d/regex-2025.10.23-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f13450328a6634348d47a88367e06b64c9d84980ef6a748f717b13f8ce64e87", size = 789739, upload-time = "2025-10-21T15:56:01.967Z" },
{ url = "https://files.pythonhosted.org/packages/f9/7b/e8ce8eef42a15f2c3461f8b3e6e924bbc86e9605cb534a393aadc8d3aff8/regex-2025.10.23-cp313-cp313-win32.whl", hash = "sha256:37be9296598a30c6a20236248cb8b2c07ffd54d095b75d3a2a2ee5babdc51df1", size = 266054, upload-time = "2025-10-21T15:56:05.291Z" },
{ url = "https://files.pythonhosted.org/packages/71/2d/55184ed6be6473187868d2f2e6a0708195fc58270e62a22cbf26028f2570/regex-2025.10.23-cp313-cp313-win_amd64.whl", hash = "sha256:ea7a3c283ce0f06fe789365841e9174ba05f8db16e2fd6ae00a02df9572c04c0", size = 276917, upload-time = "2025-10-21T15:56:07.303Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d4/927eced0e2bd45c45839e556f987f8c8f8683268dd3c00ad327deb3b0172/regex-2025.10.23-cp313-cp313-win_arm64.whl", hash = "sha256:d9a4953575f300a7bab71afa4cd4ac061c7697c89590a2902b536783eeb49a4f", size = 270105, upload-time = "2025-10-21T15:56:09.857Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b3/95b310605285573341fc062d1d30b19a54f857530e86c805f942c4ff7941/regex-2025.10.23-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:7d6606524fa77b3912c9ef52a42ef63c6cfbfc1077e9dc6296cd5da0da286044", size = 491850, upload-time = "2025-10-21T15:56:11.685Z" },
{ url = "https://files.pythonhosted.org/packages/a4/8f/207c2cec01e34e56db1eff606eef46644a60cf1739ecd474627db90ad90b/regex-2025.10.23-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c037aadf4d64bdc38af7db3dbd34877a057ce6524eefcb2914d6d41c56f968cc", size = 292537, upload-time = "2025-10-21T15:56:13.963Z" },
{ url = "https://files.pythonhosted.org/packages/98/3b/025240af4ada1dc0b5f10d73f3e5122d04ce7f8908ab8881e5d82b9d61b6/regex-2025.10.23-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:99018c331fb2529084a0c9b4c713dfa49fafb47c7712422e49467c13a636c656", size = 290904, upload-time = "2025-10-21T15:56:16.016Z" },
{ url = "https://files.pythonhosted.org/packages/81/8e/104ac14e2d3450c43db18ec03e1b96b445a94ae510b60138f00ce2cb7ca1/regex-2025.10.23-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd8aba965604d70306eb90a35528f776e59112a7114a5162824d43b76fa27f58", size = 807311, upload-time = "2025-10-21T15:56:17.818Z" },
{ url = "https://files.pythonhosted.org/packages/19/63/78aef90141b7ce0be8a18e1782f764f6997ad09de0e05251f0d2503a914a/regex-2025.10.23-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:238e67264b4013e74136c49f883734f68656adf8257bfa13b515626b31b20f8e", size = 873241, upload-time = "2025-10-21T15:56:19.941Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a8/80eb1201bb49ae4dba68a1b284b4211ed9daa8e74dc600018a10a90399fb/regex-2025.10.23-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2eb48bd9848d66fd04826382f5e8491ae633de3233a3d64d58ceb4ecfa2113a", size = 914794, upload-time = "2025-10-21T15:56:22.488Z" },
{ url = "https://files.pythonhosted.org/packages/f0/d5/1984b6ee93281f360a119a5ca1af6a8ca7d8417861671388bf750becc29b/regex-2025.10.23-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d36591ce06d047d0c0fe2fc5f14bfbd5b4525d08a7b6a279379085e13f0e3d0e", size = 812581, upload-time = "2025-10-21T15:56:24.319Z" },
{ url = "https://files.pythonhosted.org/packages/c4/39/11ebdc6d9927172a64ae237d16763145db6bd45ebb4055c17b88edab72a7/regex-2025.10.23-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5d4ece8628d6e364302006366cea3ee887db397faebacc5dacf8ef19e064cf8", size = 795346, upload-time = "2025-10-21T15:56:26.232Z" },
{ url = "https://files.pythonhosted.org/packages/3b/b4/89a591bcc08b5e436af43315284bd233ba77daf0cf20e098d7af12f006c1/regex-2025.10.23-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:39a7e8083959cb1c4ff74e483eecb5a65d3b3e1d821b256e54baf61782c906c6", size = 868214, upload-time = "2025-10-21T15:56:28.597Z" },
{ url = "https://files.pythonhosted.org/packages/3d/ff/58ba98409c1dbc8316cdb20dafbc63ed267380a07780cafecaf5012dabc9/regex-2025.10.23-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:842d449a8fefe546f311656cf8c0d6729b08c09a185f1cad94c756210286d6a8", size = 854540, upload-time = "2025-10-21T15:56:30.875Z" },
{ url = "https://files.pythonhosted.org/packages/9a/f2/4a9e9338d67626e2071b643f828a482712ad15889d7268e11e9a63d6f7e9/regex-2025.10.23-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d614986dc68506be8f00474f4f6960e03e4ca9883f7df47744800e7d7c08a494", size = 799346, upload-time = "2025-10-21T15:56:32.725Z" },
{ url = "https://files.pythonhosted.org/packages/63/be/543d35c46bebf6f7bf2be538cca74d6585f25714700c36f37f01b92df551/regex-2025.10.23-cp313-cp313t-win32.whl", hash = "sha256:a5b7a26b51a9df473ec16a1934d117443a775ceb7b39b78670b2e21893c330c9", size = 268657, upload-time = "2025-10-21T15:56:34.577Z" },
{ url = "https://files.pythonhosted.org/packages/14/9f/4dd6b7b612037158bb2c9bcaa710e6fb3c40ad54af441b9c53b3a137a9f1/regex-2025.10.23-cp313-cp313t-win_amd64.whl", hash = "sha256:ce81c5544a5453f61cb6f548ed358cfb111e3b23f3cd42d250a4077a6be2a7b6", size = 280075, upload-time = "2025-10-21T15:56:36.767Z" },
{ url = "https://files.pythonhosted.org/packages/81/7a/5bd0672aa65d38c8da6747c17c8b441bdb53d816c569e3261013af8e83cf/regex-2025.10.23-cp313-cp313t-win_arm64.whl", hash = "sha256:e9bf7f6699f490e4e43c44757aa179dab24d1960999c84ab5c3d5377714ed473", size = 271219, upload-time = "2025-10-21T15:56:39.033Z" },
{ url = "https://files.pythonhosted.org/packages/73/f6/0caf29fec943f201fbc8822879c99d31e59c1d51a983d9843ee5cf398539/regex-2025.10.23-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5b5cb5b6344c4c4c24b2dc87b0bfee78202b07ef7633385df70da7fcf6f7cec6", size = 488960, upload-time = "2025-10-21T15:56:40.849Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7d/ebb7085b8fa31c24ce0355107cea2b92229d9050552a01c5d291c42aecea/regex-2025.10.23-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a6ce7973384c37bdf0f371a843f95a6e6f4e1489e10e0cf57330198df72959c5", size = 290932, upload-time = "2025-10-21T15:56:42.875Z" },
{ url = "https://files.pythonhosted.org/packages/27/41/43906867287cbb5ca4cee671c3cc8081e15deef86a8189c3aad9ac9f6b4d/regex-2025.10.23-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2ee3663f2c334959016b56e3bd0dd187cbc73f948e3a3af14c3caaa0c3035d10", size = 288766, upload-time = "2025-10-21T15:56:44.894Z" },
{ url = "https://files.pythonhosted.org/packages/ab/9e/ea66132776700fc77a39b1056e7a5f1308032fead94507e208dc6716b7cd/regex-2025.10.23-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2003cc82a579107e70d013482acce8ba773293f2db534fb532738395c557ff34", size = 798884, upload-time = "2025-10-21T15:56:47.178Z" },
{ url = "https://files.pythonhosted.org/packages/d5/99/aed1453687ab63819a443930770db972c5c8064421f0d9f5da9ad029f26b/regex-2025.10.23-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:182c452279365a93a9f45874f7f191ec1c51e1f1eb41bf2b16563f1a40c1da3a", size = 864768, upload-time = "2025-10-21T15:56:49.793Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/732fe747a1304805eb3853ce6337eea16b169f7105a0d0dd9c6a5ffa9948/regex-2025.10.23-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b1249e9ff581c5b658c8f0437f883b01f1edcf424a16388591e7c05e5e9e8b0c", size = 911394, upload-time = "2025-10-21T15:56:52.186Z" },
{ url = "https://files.pythonhosted.org/packages/5e/48/58a1f6623466522352a6efa153b9a3714fc559d9f930e9bc947b4a88a2c3/regex-2025.10.23-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b841698f93db3ccc36caa1900d2a3be281d9539b822dc012f08fc80b46a3224", size = 803145, upload-time = "2025-10-21T15:56:55.142Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f6/7dea79be2681a5574ab3fc237aa53b2c1dfd6bd2b44d4640b6c76f33f4c1/regex-2025.10.23-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:956d89e0c92d471e8f7eee73f73fdff5ed345886378c45a43175a77538a1ffe4", size = 787831, upload-time = "2025-10-21T15:56:57.203Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ad/07b76950fbbe65f88120ca2d8d845047c401450f607c99ed38862904671d/regex-2025.10.23-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5c259cb363299a0d90d63b5c0d7568ee98419861618a95ee9d91a41cb9954462", size = 859162, upload-time = "2025-10-21T15:56:59.195Z" },
{ url = "https://files.pythonhosted.org/packages/41/87/374f3b2021b22aa6a4fc0b750d63f9721e53d1631a238f7a1c343c1cd288/regex-2025.10.23-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:185d2b18c062820b3a40d8fefa223a83f10b20a674bf6e8c4a432e8dfd844627", size = 849899, upload-time = "2025-10-21T15:57:01.747Z" },
{ url = "https://files.pythonhosted.org/packages/12/4a/7f7bb17c5a5a9747249807210e348450dab9212a46ae6d23ebce86ba6a2b/regex-2025.10.23-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:281d87fa790049c2b7c1b4253121edd80b392b19b5a3d28dc2a77579cb2a58ec", size = 789372, upload-time = "2025-10-21T15:57:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/c9/dd/9c7728ff544fea09bbc8635e4c9e7c423b11c24f1a7a14e6ac4831466709/regex-2025.10.23-cp314-cp314-win32.whl", hash = "sha256:63b81eef3656072e4ca87c58084c7a9c2b81d41a300b157be635a8a675aacfb8", size = 271451, upload-time = "2025-10-21T15:57:06.266Z" },
{ url = "https://files.pythonhosted.org/packages/48/f8/ef7837ff858eb74079c4804c10b0403c0b740762e6eedba41062225f7117/regex-2025.10.23-cp314-cp314-win_amd64.whl", hash = "sha256:0967c5b86f274800a34a4ed862dfab56928144d03cb18821c5153f8777947796", size = 280173, upload-time = "2025-10-21T15:57:08.206Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d0/d576e1dbd9885bfcd83d0e90762beea48d9373a6f7ed39170f44ed22e336/regex-2025.10.23-cp314-cp314-win_arm64.whl", hash = "sha256:c70dfe58b0a00b36aa04cdb0f798bf3e0adc31747641f69e191109fd8572c9a9", size = 273206, upload-time = "2025-10-21T15:57:10.367Z" },
{ url = "https://files.pythonhosted.org/packages/a6/d0/2025268315e8b2b7b660039824cb7765a41623e97d4cd421510925400487/regex-2025.10.23-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1f5799ea1787aa6de6c150377d11afad39a38afd033f0c5247aecb997978c422", size = 491854, upload-time = "2025-10-21T15:57:12.526Z" },
{ url = "https://files.pythonhosted.org/packages/44/35/5681c2fec5e8b33454390af209c4353dfc44606bf06d714b0b8bd0454ffe/regex-2025.10.23-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a9639ab7540cfea45ef57d16dcbea2e22de351998d614c3ad2f9778fa3bdd788", size = 292542, upload-time = "2025-10-21T15:57:15.158Z" },
{ url = "https://files.pythonhosted.org/packages/5d/17/184eed05543b724132e4a18149e900f5189001fcfe2d64edaae4fbaf36b4/regex-2025.10.23-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:08f52122c352eb44c3421dab78b9b73a8a77a282cc8314ae576fcaa92b780d10", size = 290903, upload-time = "2025-10-21T15:57:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/25/d0/5e3347aa0db0de382dddfa133a7b0ae72f24b4344f3989398980b44a3924/regex-2025.10.23-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebf1baebef1c4088ad5a5623decec6b52950f0e4d7a0ae4d48f0a99f8c9cb7d7", size = 807546, upload-time = "2025-10-21T15:57:19.179Z" },
{ url = "https://files.pythonhosted.org/packages/d2/bb/40c589bbdce1be0c55e9f8159789d58d47a22014f2f820cf2b517a5cd193/regex-2025.10.23-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:16b0f1c2e2d566c562d5c384c2b492646be0a19798532fdc1fdedacc66e3223f", size = 873322, upload-time = "2025-10-21T15:57:21.36Z" },
{ url = "https://files.pythonhosted.org/packages/fe/56/a7e40c01575ac93360e606278d359f91829781a9f7fb6e5aa435039edbda/regex-2025.10.23-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7ada5d9dceafaab92646aa00c10a9efd9b09942dd9b0d7c5a4b73db92cc7e61", size = 914855, upload-time = "2025-10-21T15:57:24.044Z" },
{ url = "https://files.pythonhosted.org/packages/5c/4b/d55587b192763db3163c3f508b3b67b31bb6f5e7a0e08b83013d0a59500a/regex-2025.10.23-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a36b4005770044bf08edecc798f0e41a75795b9e7c9c12fe29da8d792ef870c", size = 812724, upload-time = "2025-10-21T15:57:26.123Z" },
{ url = "https://files.pythonhosted.org/packages/33/20/18bac334955fbe99d17229f4f8e98d05e4a501ac03a442be8facbb37c304/regex-2025.10.23-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:af7b2661dcc032da1fae82069b5ebf2ac1dfcd5359ef8b35e1367bfc92181432", size = 795439, upload-time = "2025-10-21T15:57:28.497Z" },
{ url = "https://files.pythonhosted.org/packages/67/46/c57266be9df8549c7d85deb4cb82280cb0019e46fff677534c5fa1badfa4/regex-2025.10.23-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1cb976810ac1416a67562c2e5ba0accf6f928932320fef302e08100ed681b38e", size = 868336, upload-time = "2025-10-21T15:57:30.867Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f3/bd5879e41ef8187fec5e678e94b526a93f99e7bbe0437b0f2b47f9101694/regex-2025.10.23-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:1a56a54be3897d62f54290190fbcd754bff6932934529fbf5b29933da28fcd43", size = 854567, upload-time = "2025-10-21T15:57:33.062Z" },
{ url = "https://files.pythonhosted.org/packages/e6/57/2b6bbdbd2f24dfed5b028033aa17ad8f7d86bb28f1a892cac8b3bc89d059/regex-2025.10.23-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8f3e6d202fb52c2153f532043bbcf618fd177df47b0b306741eb9b60ba96edc3", size = 799565, upload-time = "2025-10-21T15:57:35.153Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ba/a6168f542ba73b151ed81237adf6b869c7b2f7f8d51618111296674e20ee/regex-2025.10.23-cp314-cp314t-win32.whl", hash = "sha256:1fa1186966b2621b1769fd467c7b22e317e6ba2d2cdcecc42ea3089ef04a8521", size = 274428, upload-time = "2025-10-21T15:57:37.996Z" },
{ url = "https://files.pythonhosted.org/packages/ef/a0/c84475e14a2829e9b0864ebf77c3f7da909df9d8acfe2bb540ff0072047c/regex-2025.10.23-cp314-cp314t-win_amd64.whl", hash = "sha256:08a15d40ce28362eac3e78e83d75475147869c1ff86bc93285f43b4f4431a741", size = 284140, upload-time = "2025-10-21T15:57:40.027Z" },
{ url = "https://files.pythonhosted.org/packages/51/33/6a08ade0eee5b8ba79386869fa6f77afeb835b60510f3525db987e2fffc4/regex-2025.10.23-cp314-cp314t-win_arm64.whl", hash = "sha256:a93e97338e1c8ea2649e130dcfbe8cd69bba5e1e163834752ab64dcb4de6d5ed", size = 274497, upload-time = "2025-10-21T15:57:42.389Z" },
{ url = "https://files.pythonhosted.org/packages/31/e9/f6e13de7e0983837f7b6d238ad9458800a874bf37c264f7923e63409944c/regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6", size = 489089, upload-time = "2025-11-03T21:32:50.027Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5c/261f4a262f1fa65141c1b74b255988bd2fa020cc599e53b080667d591cfc/regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4", size = 291059, upload-time = "2025-11-03T21:32:51.682Z" },
{ url = "https://files.pythonhosted.org/packages/8e/57/f14eeb7f072b0e9a5a090d1712741fd8f214ec193dba773cf5410108bb7d/regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73", size = 288900, upload-time = "2025-11-03T21:32:53.569Z" },
{ url = "https://files.pythonhosted.org/packages/3c/6b/1d650c45e99a9b327586739d926a1cd4e94666b1bd4af90428b36af66dc7/regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f", size = 799010, upload-time = "2025-11-03T21:32:55.222Z" },
{ url = "https://files.pythonhosted.org/packages/99/ee/d66dcbc6b628ce4e3f7f0cbbb84603aa2fc0ffc878babc857726b8aab2e9/regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d", size = 864893, upload-time = "2025-11-03T21:32:57.239Z" },
{ url = "https://files.pythonhosted.org/packages/bf/2d/f238229f1caba7ac87a6c4153d79947fb0261415827ae0f77c304260c7d3/regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be", size = 911522, upload-time = "2025-11-03T21:32:59.274Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3d/22a4eaba214a917c80e04f6025d26143690f0419511e0116508e24b11c9b/regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db", size = 803272, upload-time = "2025-11-03T21:33:01.393Z" },
{ url = "https://files.pythonhosted.org/packages/84/b1/03188f634a409353a84b5ef49754b97dbcc0c0f6fd6c8ede505a8960a0a4/regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62", size = 787958, upload-time = "2025-11-03T21:33:03.379Z" },
{ url = "https://files.pythonhosted.org/packages/99/6a/27d072f7fbf6fadd59c64d210305e1ff865cc3b78b526fd147db768c553b/regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f", size = 859289, upload-time = "2025-11-03T21:33:05.374Z" },
{ url = "https://files.pythonhosted.org/packages/9a/70/1b3878f648e0b6abe023172dacb02157e685564853cc363d9961bcccde4e/regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02", size = 850026, upload-time = "2025-11-03T21:33:07.131Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d5/68e25559b526b8baab8e66839304ede68ff6727237a47727d240006bd0ff/regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed", size = 789499, upload-time = "2025-11-03T21:33:09.141Z" },
{ url = "https://files.pythonhosted.org/packages/fc/df/43971264857140a350910d4e33df725e8c94dd9dee8d2e4729fa0d63d49e/regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4", size = 271604, upload-time = "2025-11-03T21:33:10.9Z" },
{ url = "https://files.pythonhosted.org/packages/01/6f/9711b57dc6894a55faf80a4c1b5aa4f8649805cb9c7aef46f7d27e2b9206/regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad", size = 280320, upload-time = "2025-11-03T21:33:12.572Z" },
{ url = "https://files.pythonhosted.org/packages/f1/7e/f6eaa207d4377481f5e1775cdeb5a443b5a59b392d0065f3417d31d80f87/regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f", size = 273372, upload-time = "2025-11-03T21:33:14.219Z" },
{ url = "https://files.pythonhosted.org/packages/c3/06/49b198550ee0f5e4184271cee87ba4dfd9692c91ec55289e6282f0f86ccf/regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc", size = 491985, upload-time = "2025-11-03T21:33:16.555Z" },
{ url = "https://files.pythonhosted.org/packages/ce/bf/abdafade008f0b1c9da10d934034cb670432d6cf6cbe38bbb53a1cfd6cf8/regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49", size = 292669, upload-time = "2025-11-03T21:33:18.32Z" },
{ url = "https://files.pythonhosted.org/packages/f9/ef/0c357bb8edbd2ad8e273fcb9e1761bc37b8acbc6e1be050bebd6475f19c1/regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536", size = 291030, upload-time = "2025-11-03T21:33:20.048Z" },
{ url = "https://files.pythonhosted.org/packages/79/06/edbb67257596649b8fb088d6aeacbcb248ac195714b18a65e018bf4c0b50/regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95", size = 807674, upload-time = "2025-11-03T21:33:21.797Z" },
{ url = "https://files.pythonhosted.org/packages/f4/d9/ad4deccfce0ea336296bd087f1a191543bb99ee1c53093dcd4c64d951d00/regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009", size = 873451, upload-time = "2025-11-03T21:33:23.741Z" },
{ url = "https://files.pythonhosted.org/packages/13/75/a55a4724c56ef13e3e04acaab29df26582f6978c000ac9cd6810ad1f341f/regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9", size = 914980, upload-time = "2025-11-03T21:33:25.999Z" },
{ url = "https://files.pythonhosted.org/packages/67/1e/a1657ee15bd9116f70d4a530c736983eed997b361e20ecd8f5ca3759d5c5/regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d", size = 812852, upload-time = "2025-11-03T21:33:27.852Z" },
{ url = "https://files.pythonhosted.org/packages/b8/6f/f7516dde5506a588a561d296b2d0044839de06035bb486b326065b4c101e/regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6", size = 795566, upload-time = "2025-11-03T21:33:32.364Z" },
{ url = "https://files.pythonhosted.org/packages/d9/dd/3d10b9e170cc16fb34cb2cef91513cf3df65f440b3366030631b2984a264/regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154", size = 868463, upload-time = "2025-11-03T21:33:34.459Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8e/935e6beff1695aa9085ff83195daccd72acc82c81793df480f34569330de/regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267", size = 854694, upload-time = "2025-11-03T21:33:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/92/12/10650181a040978b2f5720a6a74d44f841371a3d984c2083fc1752e4acf6/regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379", size = 799691, upload-time = "2025-11-03T21:33:39.079Z" },
{ url = "https://files.pythonhosted.org/packages/67/90/8f37138181c9a7690e7e4cb388debbd389342db3c7381d636d2875940752/regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38", size = 274583, upload-time = "2025-11-03T21:33:41.302Z" },
{ url = "https://files.pythonhosted.org/packages/8f/cd/867f5ec442d56beb56f5f854f40abcfc75e11d10b11fdb1869dd39c63aaf/regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de", size = 284286, upload-time = "2025-11-03T21:33:43.324Z" },
{ url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741, upload-time = "2025-11-03T21:33:45.557Z" },
]
[[package]]
@ -1130,68 +1001,39 @@ wheels = [
[[package]]
name = "rpds-py"
version = "0.28.0"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419, upload-time = "2025-10-22T22:24:29.327Z" }
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/03/ce566d92611dfac0085c2f4b048cd53ed7c274a5c05974b882a908d540a2/rpds_py-0.28.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b", size = 366235, upload-time = "2025-10-22T22:22:28.397Z" },
{ url = "https://files.pythonhosted.org/packages/00/34/1c61da1b25592b86fd285bd7bd8422f4c9d748a7373b46126f9ae792a004/rpds_py-0.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a", size = 348241, upload-time = "2025-10-22T22:22:30.171Z" },
{ url = "https://files.pythonhosted.org/packages/fc/00/ed1e28616848c61c493a067779633ebf4b569eccaacf9ccbdc0e7cba2b9d/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa", size = 378079, upload-time = "2025-10-22T22:22:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/11/b2/ccb30333a16a470091b6e50289adb4d3ec656fd9951ba8c5e3aaa0746a67/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724", size = 393151, upload-time = "2025-10-22T22:22:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d0/73e2217c3ee486d555cb84920597480627d8c0240ff3062005c6cc47773e/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491", size = 517520, upload-time = "2025-10-22T22:22:34.949Z" },
{ url = "https://files.pythonhosted.org/packages/c4/91/23efe81c700427d0841a4ae7ea23e305654381831e6029499fe80be8a071/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399", size = 408699, upload-time = "2025-10-22T22:22:36.584Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ee/a324d3198da151820a326c1f988caaa4f37fc27955148a76fff7a2d787a9/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6", size = 385720, upload-time = "2025-10-22T22:22:38.014Z" },
{ url = "https://files.pythonhosted.org/packages/19/ad/e68120dc05af8b7cab4a789fccd8cdcf0fe7e6581461038cc5c164cd97d2/rpds_py-0.28.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d", size = 401096, upload-time = "2025-10-22T22:22:39.869Z" },
{ url = "https://files.pythonhosted.org/packages/99/90/c1e070620042459d60df6356b666bb1f62198a89d68881816a7ed121595a/rpds_py-0.28.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb", size = 411465, upload-time = "2025-10-22T22:22:41.395Z" },
{ url = "https://files.pythonhosted.org/packages/68/61/7c195b30d57f1b8d5970f600efee72a4fad79ec829057972e13a0370fd24/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41", size = 558832, upload-time = "2025-10-22T22:22:42.871Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3d/06f3a718864773f69941d4deccdf18e5e47dd298b4628062f004c10f3b34/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7", size = 583230, upload-time = "2025-10-22T22:22:44.877Z" },
{ url = "https://files.pythonhosted.org/packages/66/df/62fc783781a121e77fee9a21ead0a926f1b652280a33f5956a5e7833ed30/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9", size = 553268, upload-time = "2025-10-22T22:22:46.441Z" },
{ url = "https://files.pythonhosted.org/packages/84/85/d34366e335140a4837902d3dea89b51f087bd6a63c993ebdff59e93ee61d/rpds_py-0.28.0-cp313-cp313-win32.whl", hash = "sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5", size = 217100, upload-time = "2025-10-22T22:22:48.342Z" },
{ url = "https://files.pythonhosted.org/packages/3c/1c/f25a3f3752ad7601476e3eff395fe075e0f7813fbb9862bd67c82440e880/rpds_py-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e", size = 227759, upload-time = "2025-10-22T22:22:50.219Z" },
{ url = "https://files.pythonhosted.org/packages/e0/d6/5f39b42b99615b5bc2f36ab90423ea404830bdfee1c706820943e9a645eb/rpds_py-0.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1", size = 217326, upload-time = "2025-10-22T22:22:51.647Z" },
{ url = "https://files.pythonhosted.org/packages/5c/8b/0c69b72d1cee20a63db534be0df271effe715ef6c744fdf1ff23bb2b0b1c/rpds_py-0.28.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c", size = 355736, upload-time = "2025-10-22T22:22:53.211Z" },
{ url = "https://files.pythonhosted.org/packages/f7/6d/0c2ee773cfb55c31a8514d2cece856dd299170a49babd50dcffb15ddc749/rpds_py-0.28.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa", size = 342677, upload-time = "2025-10-22T22:22:54.723Z" },
{ url = "https://files.pythonhosted.org/packages/e2/1c/22513ab25a27ea205144414724743e305e8153e6abe81833b5e678650f5a/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b", size = 371847, upload-time = "2025-10-22T22:22:56.295Z" },
{ url = "https://files.pythonhosted.org/packages/60/07/68e6ccdb4b05115ffe61d31afc94adef1833d3a72f76c9632d4d90d67954/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d", size = 381800, upload-time = "2025-10-22T22:22:57.808Z" },
{ url = "https://files.pythonhosted.org/packages/73/bf/6d6d15df80781d7f9f368e7c1a00caf764436518c4877fb28b029c4624af/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe", size = 518827, upload-time = "2025-10-22T22:22:59.826Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d3/2decbb2976cc452cbf12a2b0aaac5f1b9dc5dd9d1f7e2509a3ee00421249/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a", size = 399471, upload-time = "2025-10-22T22:23:01.968Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2c/f30892f9e54bd02e5faca3f6a26d6933c51055e67d54818af90abed9748e/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc", size = 377578, upload-time = "2025-10-22T22:23:03.52Z" },
{ url = "https://files.pythonhosted.org/packages/f0/5d/3bce97e5534157318f29ac06bf2d279dae2674ec12f7cb9c12739cee64d8/rpds_py-0.28.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259", size = 390482, upload-time = "2025-10-22T22:23:05.391Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f0/886bd515ed457b5bd93b166175edb80a0b21a210c10e993392127f1e3931/rpds_py-0.28.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a", size = 402447, upload-time = "2025-10-22T22:23:06.93Z" },
{ url = "https://files.pythonhosted.org/packages/42/b5/71e8777ac55e6af1f4f1c05b47542a1eaa6c33c1cf0d300dca6a1c6e159a/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f", size = 552385, upload-time = "2025-10-22T22:23:08.557Z" },
{ url = "https://files.pythonhosted.org/packages/5d/cb/6ca2d70cbda5a8e36605e7788c4aa3bea7c17d71d213465a5a675079b98d/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37", size = 575642, upload-time = "2025-10-22T22:23:10.348Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d4/407ad9960ca7856d7b25c96dcbe019270b5ffdd83a561787bc682c797086/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712", size = 544507, upload-time = "2025-10-22T22:23:12.434Z" },
{ url = "https://files.pythonhosted.org/packages/51/31/2f46fe0efcac23fbf5797c6b6b7e1c76f7d60773e525cb65fcbc582ee0f2/rpds_py-0.28.0-cp313-cp313t-win32.whl", hash = "sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342", size = 205376, upload-time = "2025-10-22T22:23:13.979Z" },
{ url = "https://files.pythonhosted.org/packages/92/e4/15947bda33cbedfc134490a41841ab8870a72a867a03d4969d886f6594a2/rpds_py-0.28.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907", size = 215907, upload-time = "2025-10-22T22:23:15.5Z" },
{ url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830, upload-time = "2025-10-22T22:23:17.03Z" },
{ url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819, upload-time = "2025-10-22T22:23:18.57Z" },
{ url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127, upload-time = "2025-10-22T22:23:20.216Z" },
{ url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767, upload-time = "2025-10-22T22:23:21.787Z" },
{ url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585, upload-time = "2025-10-22T22:23:23.699Z" },
{ url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828, upload-time = "2025-10-22T22:23:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509, upload-time = "2025-10-22T22:23:27.32Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014, upload-time = "2025-10-22T22:23:29.332Z" },
{ url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410, upload-time = "2025-10-22T22:23:31.14Z" },
{ url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593, upload-time = "2025-10-22T22:23:32.834Z" },
{ url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925, upload-time = "2025-10-22T22:23:34.47Z" },
{ url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444, upload-time = "2025-10-22T22:23:36.093Z" },
{ url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968, upload-time = "2025-10-22T22:23:37.638Z" },
{ url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876, upload-time = "2025-10-22T22:23:39.179Z" },
{ url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506, upload-time = "2025-10-22T22:23:40.755Z" },
{ url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433, upload-time = "2025-10-22T22:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601, upload-time = "2025-10-22T22:23:44.372Z" },
{ url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039, upload-time = "2025-10-22T22:23:46.025Z" },
{ url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407, upload-time = "2025-10-22T22:23:48.098Z" },
{ url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172, upload-time = "2025-10-22T22:23:50.16Z" },
{ url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020, upload-time = "2025-10-22T22:23:51.81Z" },
{ url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451, upload-time = "2025-10-22T22:23:53.711Z" },
{ url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355, upload-time = "2025-10-22T22:23:55.299Z" },
{ url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146, upload-time = "2025-10-22T22:23:56.929Z" },
{ url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656, upload-time = "2025-10-22T22:23:58.62Z" },
{ url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782, upload-time = "2025-10-22T22:24:00.312Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671, upload-time = "2025-10-22T22:24:02.297Z" },
{ url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749, upload-time = "2025-10-22T22:24:03.848Z" },
{ url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233, upload-time = "2025-10-22T22:24:05.471Z" },
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
]
[[package]]
@ -1217,27 +1059,27 @@ wheels = [
[[package]]
name = "s3transfer"
version = "0.14.0"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" }
sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" },
{ url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.42.1"
version = "2.46.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/04/ec8c1dd9250847303d98516e917978cb1c7083024770d86d657d2ccb5a70/sentry_sdk-2.42.1.tar.gz", hash = "sha256:8598cc6edcfe74cb8074ba6a7c15338cdee93d63d3eb9b9943b4b568354ad5b6", size = 354839, upload-time = "2025-10-20T12:38:40.45Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/d7/c140a5837649e2bf2ec758494fde1d9a016c76777eab64e75ef38d685bbb/sentry_sdk-2.46.0.tar.gz", hash = "sha256:91821a23460725734b7741523021601593f35731808afc0bb2ba46c27b8acd91", size = 374761, upload-time = "2025-11-24T09:34:13.932Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/cb/c21b96ff379923310b4fb2c06e8d560d801e24aeb300faa72a04776868fc/sentry_sdk-2.42.1-py2.py3-none-any.whl", hash = "sha256:f8716b50c927d3beb41bc88439dc6bcd872237b596df5b14613e2ade104aee02", size = 380952, upload-time = "2025-10-20T12:38:38.88Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b6/ce7c502a366f4835b1f9c057753f6989a92d3c70cbadb168193f5fb7499b/sentry_sdk-2.46.0-py2.py3-none-any.whl", hash = "sha256:4eeeb60198074dff8d066ea153fa6f241fef1668c10900ea53a4200abc8da9b1", size = 406266, upload-time = "2025-11-24T09:34:12.114Z" },
]
[package.optional-dependencies]
@ -1291,14 +1133,14 @@ dev = [
requires-dist = [
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "cryptography", specifier = ">=46.0.3" },
{ name = "django", specifier = "==5.2.7" },
{ name = "django-allauth", specifier = ">=65.12.1" },
{ name = "django", specifier = "==5.2.8" },
{ name = "django-allauth", specifier = ">=65.13.1" },
{ name = "django-auditlog", specifier = ">=3.3.0" },
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" },
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.1" },
{ name = "django-jsonform", specifier = ">=2.23.2" },
{ name = "django-scopes", specifier = ">=2.0.0" },
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
{ name = "django-template-partials", specifier = ">=25.2" },
{ name = "django-template-partials", specifier = ">=25.3" },
{ name = "jsonschema", specifier = ">=4.25.1" },
{ name = "kubernetes", specifier = ">=34.1.0" },
{ name = "pillow", specifier = ">=12.0.0" },
@ -1306,21 +1148,21 @@ requires-dist = [
{ name = "pyjwt", specifier = ">=2.10.1" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "rules", specifier = ">=3.5" },
{ name = "sentry-sdk", extras = ["django"], specifier = ">=2.42.1" },
{ name = "sentry-sdk", extras = ["django"], specifier = ">=2.46.0" },
{ name = "urlman", specifier = ">=2.0.2" },
]
[package.metadata.requires-dev]
dev = [
{ name = "black", specifier = ">=25.9.0" },
{ name = "black", specifier = ">=25.11.0" },
{ name = "bumpver", specifier = ">=2025.1131" },
{ name = "coverage", specifier = ">=7.11.0" },
{ name = "coverage", specifier = ">=7.12.0" },
{ name = "djlint", specifier = ">=1.36.4" },
{ name = "flake8", specifier = ">=7.3.0" },
{ name = "flake8-bugbear", specifier = ">=25.10.21" },
{ name = "flake8-bugbear", specifier = ">=25.11.29" },
{ name = "flake8-pyproject", specifier = ">=1.2.3" },
{ name = "isort", specifier = ">=7.0.0" },
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest", specifier = ">=9.0.1" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "pytest-django", specifier = ">=4.11.1" },
{ name = "pytest-mock", specifier = ">=3.15.1" },
@ -1337,11 +1179,11 @@ wheels = [
[[package]]
name = "sqlparse"
version = "0.5.3"
version = "0.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
sdist = { url = "https://files.pythonhosted.org/packages/18/67/701f86b28d63b2086de47c942eccf8ca2208b3be69715a1119a4e384415a/sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e", size = 120112, upload-time = "2025-11-28T07:10:18.377Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
{ url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" },
]
[[package]]