65 Commits

Author SHA1 Message Date
ffc53f1b54 feat: refunds, failed and pending payments work 2026-05-20 20:27:32 +02:00
3338a1d3e7 feat(course_page.html): show notice if course doesn't have an assigned product 2026-05-20 19:14:59 +02:00
365b28a165 feat(course_page.html): use form for buy course button as checkout now individually-managed 2026-05-20 19:13:59 +02:00
41af6dcb7c chore(purchase/migrations/0008): remove obsolete PurchasableProduct.stripe_payment_url field 2026-05-20 19:12:26 +02:00
e399d98f31 chore(purchase/models.py): remove obsolete PurchasableProduct.stripe_payment_url field 2026-05-20 19:12:11 +02:00
33d2b89b07 feat(purchase/urls.py): add route to create_checkout_session view 2026-05-20 19:05:52 +02:00
118a1188d5 feat(purchase/views.py): use custom stripe session and auto-finalize purchase via webhook 2026-05-20 19:05:18 +02:00
6d927856c8 feat: add stripe webhook 2026-05-19 21:14:04 +02:00
d2c870414f feat(course_page.html): use stripe payment links 2026-05-18 18:08:21 +02:00
4dbfb8fc41 feat(base.py): add STRIPE_SUCCESS_URL 2026-05-18 18:07:58 +02:00
2065f3c9c5 feat(pages.py): add support for creating PurchasableProduct from within CoursePage 2026-05-18 18:07:39 +02:00
b24e48f1b1 chore(purchase/migrations): add migrations 0005 & 0006 2026-05-18 18:06:45 +02:00
211dcc4f67 feat(purchase/models.py): create payment links and sync name and description from coursepage 2026-05-18 18:06:16 +02:00
a3cd8d42fa feat: remove Products admin page 2026-05-18 18:05:16 +02:00
6471b98ec2 feat: add Products form to admin 2026-05-18 17:07:15 +02:00
3b46a18b29 feat(stripe_client.py): use SITE_URL in callback 2026-05-18 15:56:41 +02:00
3bc11bf58d feat(base.py): add SITE_URL const 2026-05-18 15:54:00 +02:00
9041ecd206 feat: first stripe imp 2026-05-06 19:27:00 +02:00
1fdd316d0d feat(models.py): add users to gitea teams on purchase/refund 2026-05-05 12:46:07 +02:00
da5662ceee feat(base.py): add GITEA_ROOT_URL and configure logger 2026-05-05 12:45:35 +02:00
9acb4c8d7c feat(module_lesson_page.html): auto login to gitea when viewing repo 2026-05-05 12:45:08 +02:00
36c14ab939 feat(pages.py): add gitea login redirect url property for handling links to repo 2026-05-05 12:44:37 +02:00
a9d7aef6dd feat(signals.py): add gitea repo to team 2026-05-05 12:44:01 +02:00
fa942809d9 chore(signals.py): remove unused commented code 2026-05-05 12:43:28 +02:00
29f8475b88 feat(module_lesson_page.html): add link to Gitea code 2026-04-23 19:56:09 +02:00
1875c6fd97 feat(blog_page.html): add BlogPage template 2026-04-23 19:54:51 +02:00
92aa1fc024 feat(blog_index_page.html): add BlogIndexPage template 2026-04-23 19:54:20 +02:00
3551d2b654 feat(header.html): add blog link to header 2026-04-23 19:54:02 +02:00
4c21e324f6 feat(pages.py): add posts ctx variable to BlogIndexPage 2026-04-23 19:53:41 +02:00
80a80553fe chore(home/views.py): remove debug print 2026-04-23 19:13:30 +02:00
15e74660f5 feat(admin_chat.html): add back button to admin chat 2026-04-23 19:13:10 +02:00
2b34cadf24 refactor(admin_chat.html): style chat 2026-04-23 19:09:46 +02:00
6dd3355087 refactor(user_chat.html): style chat 2026-04-23 18:58:31 +02:00
bfdc953e0f feat(header.html): add chat link to header 2026-04-23 18:57:39 +02:00
27567caf78 fix(forms.py): fix error when gitea account already exists 2026-04-23 18:57:18 +02:00
97df6349ab feat(pages.py): delete event's future occurrences if unpublished 2026-04-23 18:17:24 +02:00
6d355b603d fix(pages.py): fix event's other occurrences not being deleted when changing event from recurrent to single
Closes #1
2026-04-23 18:10:05 +02:00
f9f812caf0 chore(.gitignore): ignore gitea data 2026-04-22 20:39:59 +02:00
299a600b09 fix(docker-compose.yml): run gitea on host network 2026-04-22 20:39:47 +02:00
0cb2794eff chore(gitea): add gitea config 2026-04-22 20:39:26 +02:00
3d51c6f043 build(uv.lock): update depedencies 2026-04-22 20:38:27 +02:00
2a15556513 refactor(base.py): use relative DB path 2026-04-22 20:38:14 +02:00
ffa0b03661 fix(signals.py): fix repo creation even if create_gitea_repo False 2026-04-22 20:37:42 +02:00
3ec8963030 chore(0023): add EventIndexPage 2026-04-22 20:36:39 +02:00
d70bf79107 feat(pages.py): add EventIndexPage 2026-04-22 20:36:22 +02:00
daf0b05bb8 feat: add gitea docker 2026-04-02 14:59:29 +02:00
35d6bb5f2e docs(README.md): add README 2026-04-02 10:40:09 +02:00
dd936473d8 feat(signals.py): create per-lesson repositories on ModuleLessonPage save 2026-03-30 10:58:01 +02:00
787440d56f chore(migrations/0022): create BlogIndexPage and BlogPage 2026-03-30 10:13:12 +02:00
37b0a6a95b feat(pages.py): add blog pages 2026-03-30 10:13:12 +02:00
345914a519 chore(migrations/0021): add create_gitea_repo and gitea_repo_url to ModuleLessonPage 2026-03-30 10:13:12 +02:00
7e24ded8ee fix(pages.py): generate event occurrences only if live 2026-03-30 10:13:12 +02:00
64edf6656e feat(pages.py): add per-lesson repo fields 2026-03-30 10:13:03 +02:00
a2ad8e7ac9 feat(module_lesson_page.html): add link to course library 2026-03-23 14:03:38 +01:00
a0b4697c61 feat(course_module_page.html): add link to course library 2026-03-23 14:03:29 +01:00
983384f62b feat(course_page.html): add link to course library in CoursePage 2026-03-23 14:03:10 +01:00
668ddccea5 feat(settings/base.py): add LOGGING config 2026-03-23 14:02:24 +01:00
6dd826c3bd feat(home/signals.py): create gitea team and repo for course on CoursePage save 2026-03-23 14:02:07 +01:00
e74c1fb28d chore(migrations/0020): add repository_url field to CoursePage 2026-03-23 13:46:24 +01:00
cb19bc6262 feat(models/pages.py): add repository_url field to CoursePage 2026-03-23 13:45:19 +01:00
a918ee73c4 fix(models/pages.py): ensure course has ID before creating group 2026-03-23 13:44:33 +01:00
5913e847bc refactor(forms.py): move gitea account creation login to separate function 2026-03-20 14:47:12 +01:00
18b21b0892 feat(forms.py): create gitea account on signup 2026-03-20 14:37:40 +01:00
efb3799e12 feat(forms.py): capitalize first and last name 2026-03-20 14:37:22 +01:00
306d39bd22 feat(oauth_validators.py): use user ID for gitea username 2026-03-20 14:36:27 +01:00
41 changed files with 3457 additions and 284 deletions

View File

@@ -1,7 +1,7 @@
# Django project # Django project
/media/ /media/
/static/ /static/
*.sqlite3 # *.sqlite3
# Python and others # Python and others
__pycache__ __pycache__
@@ -37,3 +37,6 @@ wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
.venv/
gitea/

3
.gitignore vendored
View File

@@ -55,3 +55,6 @@ docs/ref/modules/
# Media (managed by wagtail) # Media (managed by wagtail)
media/ media/
# Gitea data
gitea/data/

View File

@@ -1,60 +1,57 @@
# Use an official Python runtime based on Debian 12 "bookworm" as a parent image. FROM python:3.14-slim-bookworm AS builder
FROM python:3.12-slim-bookworm
# Add user that will be used in the container. RUN useradd -m kursy
RUN useradd wagtail
# Port used by this container to serve HTTP. ENV NODE_VERSION=24.14.1
EXPOSE 8000
# Set environment variables. # install uv
# 1. Force Python stdout and stderr streams to be unbuffered. COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# 2. Set PORT variable that is used by Gunicorn. This should match "EXPOSE"
# command.
ENV PYTHONUNBUFFERED=1 \
PORT=8000
# Install system packages required by Wagtail and Django. # install nodejs
RUN apt-get update --yes --quiet && apt-get install --yes --quiet --no-install-recommends \ RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
build-essential \ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
libpq-dev \ apt-get install -y nodejs
libmariadb-dev \
libjpeg62-turbo-dev \
zlib1g-dev \
libwebp-dev \
&& rm -rf /var/lib/apt/lists/*
# Install the application server. # copy project files
RUN pip install "gunicorn==20.0.4" WORKDIR /app
COPY pyproject.toml uv.lock ./
# Install the project requirements. RUN chown kursy:kursy /app -R
COPY requirements.txt /
RUN pip install -r /requirements.txt
# Use /app folder as a directory where the source code is stored. USER kursy
# install project dependencies
RUN uv sync
COPY --chown=kursy:kursy . .
# install tailwind
WORKDIR /app/theme/static_src
RUN npm install
# collect static files
WORKDIR /app
RUN uv run python manage.py collectstatic --noinput --clear
# --- RUNTIME IMAGE ---
FROM python:3.14-slim-bookworm
RUN useradd -m kursy
WORKDIR /app WORKDIR /app
# Set this directory to be owned by the "wagtail" user. This Wagtail project RUN mkdir -p /app/data && chown kursy:kursy /app/data
# uses SQLite, the folder needs to be owned by the user that
# will be writing to the database file.
RUN chown wagtail:wagtail /app
# Copy the source code of the project into the container. USER kursy
COPY --chown=wagtail:wagtail . .
# Use user "wagtail" to run the build commands below and the server itself. COPY --from=builder --chown=kursy:kursy /app/.venv /app/.venv
USER wagtail COPY --from=builder --chown=kursy:kursy /app /app
# Collect static files. ENV PATH="/app/.venv/bin:$PATH" \
RUN python manage.py collectstatic --noinput --clear PYTHONUNBUFFERED=1 \
PORT=8000
EXPOSE 8000
CMD set -xe; python manage.py migrate --noinput; python manage.py runserver 0.0.0.0:8000
# Runtime command that executes when "docker run" is called, it does the
# following:
# 1. Migrate the database.
# 2. Start the application server.
# WARNING:
# Migrating database at the same time as starting the server IS NOT THE BEST
# PRACTICE. The database should be migrated manually or using the release
# phase facilities of your hosting platform. This is used only so the
# Wagtail instance can be started with a simple "docker run" command.
CMD set -xe; python manage.py migrate --noinput; gunicorn kursy.wsgi:application

89
README.md Normal file
View File

@@ -0,0 +1,89 @@
# Studio77 (kursy)
## Instalacja
### Wymagania
- `uv` >= 0.11.0
- `python` >= 3.14 (instalowany automatycznie przez `uv`)
- `node` >= 24.14.1 (LTS)
- `npm` >= 9.2.0 (LTS)
### Instalacja wymaganych narzędzi
1. `uv` - można zainstalować za pomocą skryptu instalacyjnego dostępnego na [stronie projektu](https://docs.astral.sh/uv/getting-started/installation):
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
2. `node` i `npm` - można zainstalować za pomocą [Node Version Manager (nvm)](https://nodejs.org/en/download):
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
\. "$HOME/.nvm/nvm.sh"
nvm install 24
```
### Instalacja projektu
1. Sklonuj repozytorium:
```bash
git clone http://192.168.190:3000/StudioCodeLab/kursy.git
cd kursy
```
2. Zainstaluj zależności:
```bash
uv sync
```
3. Zainstaluj zależności tailwind:
```bash
cd theme/static_src
npm install
cd ../..
```
## Pierwsze uruchomienie
1. Wykonaj migracje bazy danych:
```bash
uv run python manage.py migrate
```
2. Utwórz superużytkownika (admina):
```bash
uv run python manage.py createsuperuser
```
## Użycie
### Uruchom serwery
- Django:
```bash
uv run python manage.py runserver
```
- Tailwind:
```bash
cd theme/static_src
npm run dev
```
> [!IMPORTANT]
> Oba serwery muszą być uruchomione równolegle, aby aplikacja działała poprawnie.
## Autorzy
- [Artur Borecki](https://github.com/pufereq)

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
networks:
gitea:
external: false
services:
gitea:
image: docker.gitea.com/gitea:latest
container_name: gitea
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=sqlite3
- GITEA__server__ROOT_URL=http://localhost:3000/
restart: always
network_mode: host
# networks:
# - gitea
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./gitea/data:/data
# - ./gitea/config:/data/gitea/conf
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "3022:3022"
# kursy:
# build: .
# container_name: kursy
# restart: unless-stopped
# networks:
# - gitea
# volumes:
# - ./db:/app/db
# ports:
# - "8000:8000"

820
gitea/config/app.ini Normal file
View File

@@ -0,0 +1,820 @@
; ; Copy required sections to your own app.ini (default is custom/conf/app.ini)
; ; and modify as needed.
; ; Do not copy the whole file as-is, as it contains some invalid sections for illustrative purposes.
; ; If you don't know what a setting is you should not set it.
; ;
; ; see https://docs.gitea.com/administration/config-cheat-sheet for additional documentation.
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ; Default Configuration (non-`app.ini` configuration)
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; These values are environment-dependent but form the basis of a lot of values. They will be
; ; reported as part of the default configuration when running `gitea help` or on start-up. The order they are emitted there is slightly different but we will list them here in the order they are set-up.
; ;
; ; - _`AppPath`_: This is the absolute path of the running gitea binary.
; ; - _`AppWorkPath`_: This refers to "working path" of the `gitea` binary. It is determined by using the first set thing in the following hierarchy:
; ; - The "WORK_PATH" option in "app.ini" file
; ; - The `--work-path` flag passed to the binary
; ; - The environment variable `$GITEA_WORK_DIR`
; ; - A built-in value set at build time (see building from source)
; ; - Otherwise it defaults to the directory of the _`AppPath`_
; ; - If any of the above are relative paths then they are made absolute against the directory of the _`AppPath`_
; ; - _`CustomPath`_: This is the base directory for custom templates and other options. It is determined by using the first set thing in the following hierarchy:
; ; - The `--custom-path` flag passed to the binary
; ; - The environment variable `$GITEA_CUSTOM`
; ; - A built-in value set at build time (see building from source)
; ; - Otherwise it defaults to _`AppWorkPath`_`/custom`
; ; - If any of the above are relative paths then they are made absolute against the directory of the _`AppWorkPath`_
; ; - _`CustomConf`_: This is the path to the `app.ini` file.
; ; - The `--config` flag passed to the binary
; ; - A built-in value set at build time (see building from source)
; ; - Otherwise it defaults to _`CustomPath`_`/conf/app.ini`
; ; - If any of the above are relative paths then they are made absolute against the directory of the _`CustomPath`_
; ;
; ; In addition there is _`StaticRootPath`_ which can be set as a built-in at build time, but will otherwise default to _`AppWorkPath`_
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ; General Settings
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; App name that shows in every page title
; Gitea: Git with a cup of tea
APP_NAME = Studio77 Gitea (test instance)
; ;
; ; RUN_USER will automatically detect the current user - but you can set it here change it if you run locally
; git
RUN_USER = gitea
WORK_PATH = /var/lib/gitea
RUN_MODE = prod
; ;
; ; Application run mode, affects performance and debugging: "dev" or "prod", default is "prod"
; ; Mode "dev" makes Gitea easier to develop and debug, values other than "dev" are treated as "prod" which is for production use.
; RUN_MODE = prod
; ;
; ; The working directory, see the comment of AppWorkPath above
; WORK_PATH =
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[server]
SSH_DOMAIN = 127.0.0.1
DOMAIN = 127.0.0.1
HTTP_PORT = 3000
ROOT_URL = http://localhost:3000/
APP_DATA_PATH = /var/lib/gitea/data
DISABLE_SSH = false
SSH_PORT = 22
LFS_START_SERVER = true
LFS_JWT_SECRET = ZlMvLXiVZtuaVymLGuO4Qg5dFHCQ45fIzui99Qw9ecM
OFFLINE_MODE = true
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; The protocol the server listens on. One of "http", "https", "http+unix", "fcgi" or "fcgi+unix".
; PROTOCOL = http
; ;
; ; Set the domain for the server.
; DOMAIN = localhost
; ;
; ; The AppURL is used to generate public URL links, defaults to "{PROTOCOL}://{DOMAIN}:{HTTP_PORT}/".
; ; Most users should set it to the real website URL of their Gitea instance when there is a reverse proxy.
; ROOT_URL =
; ;
; ; Controls how to detect the public URL.
; ; Although it defaults to "legacy" (to avoid breaking existing users), most instances should use the "auto" behavior,
; ; especially when the Gitea instance needs to be accessed in a container network.
; ; * legacy: detect the public URL from "Host" header if "X-Forwarded-Proto" header exists, otherwise use "ROOT_URL".
; ; * auto: always use "Host" header, and also use "X-Forwarded-Proto" header if it exists. If no "Host" header, use "ROOT_URL".
; PUBLIC_URL_DETECTION = legacy
; ;
; ; For development purpose only. It makes Gitea handle sub-path ("/sub-path/owner/repo/...") directly when debugging without a reverse proxy.
; ; DO NOT USE IT IN PRODUCTION!!!
; USE_SUB_URL_PATH = false
; ;
; ; when STATIC_URL_PREFIX is empty it will follow ROOT_URL
; STATIC_URL_PREFIX =
; ;
; ; The address to listen on. Either a IPv4/IPv6 address or the path to a unix socket.
; ; If PROTOCOL is set to "http+unix" or "fcgi+unix", this should be the name of the Unix socket file to use.
; ; Relative paths will be made absolute against the _`AppWorkPath`_.
; HTTP_ADDR = 0.0.0.0
; ;
; ; The port to listen on for "http" or "https" protocol. Leave empty when using a unix socket.
; HTTP_PORT = 3000
; ;
; ; Expect PROXY protocol headers on connections
; USE_PROXY_PROTOCOL = false
; ;
; ; Use PROXY protocol in TLS Bridging mode
; PROXY_PROTOCOL_TLS_BRIDGING = false
; ;
; ; Timeout to wait for PROXY protocol header (set to 0 to have no timeout)
; PROXY_PROTOCOL_HEADER_TIMEOUT = 5s
; ;
; ; Accept PROXY protocol headers with UNKNOWN type
; PROXY_PROTOCOL_ACCEPT_UNKNOWN = false
; ;
; ; If REDIRECT_OTHER_PORT is true, and PROTOCOL is set to https an http server
; ; will be started on PORT_TO_REDIRECT and it will redirect plain, non-secure http requests to the main
; ; ROOT_URL. Defaults are false for REDIRECT_OTHER_PORT and 80 for
; ; PORT_TO_REDIRECT.
; REDIRECT_OTHER_PORT = false
; PORT_TO_REDIRECT = 80
; ;
; ; expect PROXY protocol header on connections to https redirector, defaults to USE_PROXY_PROTOCOL
; REDIRECTOR_USE_PROXY_PROTOCOL =
; ; Minimum and maximum supported TLS versions
; SSL_MIN_VERSION=TLSv1.2
; SSL_MAX_VERSION=
; ;
; ; SSL Curve Preferences
; SSL_CURVE_PREFERENCES=X25519,P256
; ;
; ; SSL Cipher Suites
; SSL_CIPHER_SUITES=; Will default to "ecdhe_ecdsa_with_aes_256_gcm_sha384,ecdhe_rsa_with_aes_256_gcm_sha384,ecdhe_ecdsa_with_aes_128_gcm_sha256,ecdhe_rsa_with_aes_128_gcm_sha256,ecdhe_ecdsa_with_chacha20_poly1305,ecdhe_rsa_with_chacha20_poly1305" if aes is supported by hardware, otherwise chacha will be first.
; ;
; ; Timeout for any write to the connection. (Set to -1 to disable all timeouts.)
; PER_WRITE_TIMEOUT = 30s
; ;
; ; Timeout per Kb written to connections.
; PER_WRITE_PER_KB_TIMEOUT = 30s
; ;
; ; Permission for unix socket
; UNIX_SOCKET_PERMISSION = 666
; ;
; ; Local (DMZ) URL for Gitea workers (such as SSH update) accessing web service. In
; ; most cases you do not need to change the default value. Alter it only if
; ; your SSH server node is not the same as HTTP node. For different protocol, the default
; ; values are different. If `PROTOCOL` is `http+unix`, the default value is `http://unix/`.
; ; If `PROTOCOL` is `fcgi` or `fcgi+unix`, the default value is `{PROTOCOL}://{HTTP_ADDR}:{HTTP_PORT}/`.
; ; If listen on `0.0.0.0`, the default value is `{PROTOCOL}://localhost:{HTTP_PORT}/`.
; ; Otherwise the default value is `{PROTOCOL}://{HTTP_ADDR}:{HTTP_PORT}/`.
; ; Most users don't need (and shouldn't) set this value.
; LOCAL_ROOT_URL =
; ;
; ; When making local connections pass the PROXY protocol header, defaults to USE_PROXY_PROTOCOL
; LOCAL_USE_PROXY_PROTOCOL =
; ;
; ; Disable SSH feature when not available
; DISABLE_SSH = false
; ;
; ; Whether to use the builtin SSH server or not.
; START_SSH_SERVER = false
; ;
; ; Expect PROXY protocol header on connections to the built-in SSH server
; SSH_SERVER_USE_PROXY_PROTOCOL = false
; ;
; ; Username to use for the builtin SSH server. If blank, then it is the value of RUN_USER.
; BUILTIN_SSH_SERVER_USER =
; ;
; ; Domain name to be exposed in clone URL, defaults to DOMAIN or the domain part of ROOT_URL
; SSH_DOMAIN =
; ;
; ; SSH username displayed in clone URLs. It defaults to BUILTIN_SSH_SERVER_USER or RUN_USER.
; ; If it is set to "(DOER_USERNAME)", it will use current signed-in user's username.
; ; This option is only for some advanced users who have configured their SSH reverse-proxy
; ; and need to use different usernames for git SSH clone.
; ; Most users should just leave it blank.
; SSH_USER =
; ;
; ; The network interface the builtin SSH server should listen on
; SSH_LISTEN_HOST =
; ;
; ; Port number to be exposed in clone URL
; SSH_PORT = 22
; ;
; ; The port number the builtin SSH server should listen on, defaults to SSH_PORT
; SSH_LISTEN_PORT =
; ;
; ; Root path of SSH directory, default is '~/.ssh', but you have to use '/home/git/.ssh'.
; SSH_ROOT_PATH =
; ;
; ; Gitea will create a authorized_keys file by default when it is not using the internal ssh server
; ; If you intend to use the AuthorizedKeysCommand functionality then you should turn this off.
; SSH_CREATE_AUTHORIZED_KEYS_FILE = true
; ;
; ; Gitea will create a authorized_principals file by default when it is not using the internal ssh server
; ; If you intend to use the AuthorizedPrincipalsCommand functionality then you should turn this off.
; SSH_CREATE_AUTHORIZED_PRINCIPALS_FILE = true
; ;
; ; For the builtin SSH server, choose the supported ciphers/key-exchange-algorithms/MACs for SSH connections.
; ; The supported names are listed in https://github.com/golang/crypto/blob/master/ssh/common.go.
; ; Leave them empty to use the Golang crypto's recommended default values.
; ; For system SSH (non-builtin SSH server), this setting has no effect.
; SSH_SERVER_CIPHERS =
; SSH_SERVER_KEY_EXCHANGES =
; SSH_SERVER_MACS =
; ;
; ; For the built-in SSH server, choose the keypair to offer as the host key
; ; The private key should be at SSH_SERVER_HOST_KEY and the public SSH_SERVER_HOST_KEY.pub
; ; relative paths are made absolute relative to the APP_DATA_PATH
; SSH_SERVER_HOST_KEYS=ssh/gitea.rsa, ssh/gogs.rsa
; ;
; ; Enable SSH Authorized Key Backup when rewriting all keys, default is false
; SSH_AUTHORIZED_KEYS_BACKUP = false
; ;
; ; Determines which principals to allow
; ; - empty: if SSH_TRUSTED_USER_CA_KEYS is empty this will default to off, otherwise will default to email, username.
; ; - off: Do not allow authorized principals
; ; - email: the principal must match the user's email
; ; - username: the principal must match the user's username
; ; - anything: there will be no checking on the content of the principal
; SSH_AUTHORIZED_PRINCIPALS_ALLOW = email, username
; ;
; ; Enable SSH Authorized Principals Backup when rewriting all keys, default is true
; SSH_AUTHORIZED_PRINCIPALS_BACKUP = true
; ;
; ; Specifies the public keys of certificate authorities that are trusted to sign user certificates for authentication.
; ; Multiple keys should be comma separated.
; ; E.g."ssh-<algorithm> <key>". or "ssh-<algorithm> <key1>, ssh-<algorithm> <key2>".
; ; For more information see "TrustedUserCAKeys" in the sshd config manpages.
; SSH_TRUSTED_USER_CA_KEYS =
; ; Absolute path of the `TrustedUserCaKeys` file gitea will manage.
; ; Default this `RUN_USER`/.ssh/gitea-trusted-user-ca-keys.pem
; ; If you're running your own ssh server and you want to use the gitea managed file you'll also need to modify your
; ; sshd_config to point to this file. The official docker image will automatically work without further configuration.
; SSH_TRUSTED_USER_CA_KEYS_FILENAME =
; ;
; ; Enable exposure of SSH clone URL to anonymous visitors, default is false
; SSH_EXPOSE_ANONYMOUS = false
; ;
; ; Timeout for any write to ssh connections. (Set to -1 to disable all timeouts.)
; ; Will default to the PER_WRITE_TIMEOUT.
; SSH_PER_WRITE_TIMEOUT = 30s
; ;
; ; Timeout per Kb written to ssh connections.
; ; Will default to the PER_WRITE_PER_KB_TIMEOUT.
; SSH_PER_WRITE_PER_KB_TIMEOUT = 30s
; ;
; ; Indicate whether to check minimum key size with corresponding type
; MINIMUM_KEY_SIZE_CHECK = false
; ;
; ; Disable CDN even in "prod" mode
; OFFLINE_MODE = true
; ;
; ; TLS Settings: Either ACME or manual
; ; (Other common TLS configuration are found before)
; ENABLE_ACME = false
; ;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; ACME automatic TLS settings
; ;
; ; ACME directory URL (e.g. LetsEncrypt's staging/testing URL: https://acme-staging-v02.api.letsencrypt.org/directory)
; ; Leave empty to default to LetsEncrypt's (production) URL
; ACME_URL =
; ;
; ; Explicitly accept the ACME's TOS. The specific TOS cannot be retrieved at the moment.
; ACME_ACCEPTTOS = false
; ;
; ; If the ACME CA is not in your system's CA trust chain, it can be manually added here
; ACME_CA_ROOT =
; ;
; ; Email used for the ACME registration service
; ; Can be left blank to initialize at first run and use the cached value
; ACME_EMAIL =
; ;
; ; ACME live directory (not to be confused with ACME directory URL: ACME_URL)
; ; (Refer to caddy's ACME manager https://github.com/caddyserver/certmagic)
; ACME_DIRECTORY = https
; ;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; Manual TLS settings: (Only applicable if ENABLE_ACME=false)
; ;
; ; Generate steps:
; ; $ ./gitea cert -ca=true -duration=8760h0m0s -host=myhost.example.com
; ;
; ; Or from a .pfx file exported from the Windows certificate store (do
; ; not forget to export the private key):
; ; $ openssl pkcs12 -in cert.pfx -out cert.pem -nokeys
; ; $ openssl pkcs12 -in cert.pfx -out key.pem -nocerts -nodes
; ; Paths are relative to CUSTOM_PATH
; CERT_FILE = https/cert.pem
; KEY_FILE = https/key.pem
; ;
; ; Root directory containing templates and static files.
; ; default is the path where Gitea is executed
; STATIC_ROOT_PATH = ; Will default to the built-in value _`StaticRootPath`_
; ;
; ; Default path for App data
; APP_DATA_PATH = data ; relative paths will be made absolute with _`AppWorkPath`_
; ;
; ; Base path for App's temp files, leave empty to use the managed tmp directory in APP_DATA_PATH
; APP_TEMP_PATH =
; ;
; ; Enable gzip compression for runtime-generated content, static resources excluded
; ENABLE_GZIP = false
; ;
; ; Application profiling (memory and cpu)
; ; For "web" command it listens on localhost:6060
; ; For "serve" command it dumps to disk at PPROF_DATA_PATH as (cpuprofile|memprofile)_<username>_<temporary id>
; ENABLE_PPROF = false
; ;
; ; PPROF_DATA_PATH, use an absolute path when you start gitea as service
; PPROF_DATA_PATH = data/tmp/pprof ; Path is relative to _`AppWorkPath`_
; ;
; ; Landing page, can be "home", "explore", "organizations", "login", or any URL such as "/org/repo" or even "https://anotherwebsite.com"
; ; The "login" choice is not a security measure but just a UI flow change, use REQUIRE_SIGNIN_VIEW to force users to log in.
; LANDING_PAGE = home
; ;
; ; Enables git-lfs support. true or false, default is false.
; LFS_START_SERVER = false
; ;
; ; Enables git-lfs SSH protocol support. true or false, default is false.
; LFS_ALLOW_PURE_SSH = false
; ;
; ; LFS authentication secret, change this yourself
; LFS_JWT_SECRET =
; ;
; ; Alternative location to specify LFS authentication secret. You cannot specify both this and LFS_JWT_SECRET, and must pick one
; LFS_JWT_SECRET_URI = file:/etc/gitea/lfs_jwt_secret
; ;
; ; LFS authentication validity period (in time.Duration), pushes taking longer than this may fail.
; LFS_HTTP_AUTH_EXPIRY = 24h
; ;
; ; Maximum allowed LFS file size in bytes (Set to 0 for no limit).
; LFS_MAX_FILE_SIZE = 0
; ;
; ; Maximum number of locks returned per page
; LFS_LOCKS_PAGING_NUM = 50
; ;
; ; When clients make lfs batch requests, reject them if there are more pointers than this number
; ; zero means 'unlimited'
; LFS_MAX_BATCH_SIZE = 0
; ;
; ; Allow graceful restarts using SIGHUP to fork
; ALLOW_GRACEFUL_RESTARTS = true
; ;
; ; After a restart the parent will finish ongoing requests before
; ; shutting down. Force shutdown if this process takes longer than this delay.
; ; set to a negative value to disable
; GRACEFUL_HAMMER_TIME = 60s
; ;
; ; Allows the setting of a startup timeout and waithint for Windows as SVC service
; ; 0 disables this.
; STARTUP_TIMEOUT = 0
; ;
; ; Static resources, includes resources on custom/, public/ and all uploaded avatars web browser cache time. Note that this cache is disabled when RUN_MODE is "dev". Default is 6h
; STATIC_CACHE_TIME = 6h
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[database]
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; Database to use. Either "mysql", "postgres", "mssql" or "sqlite3".
; ;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; MySQL Configuration
; ;
; DB_TYPE = mysql
; HOST = 127.0.0.1:3306 ; can use socket e.g. /var/run/mysqld/mysqld.sock
; NAME = gitea
; USER = root
; PASSWD = ;Use PASSWD = `your password` for quoting if you use special characters in the password.
; SSL_MODE = false ; either "false" (default), "true", or "skip-verify"
; CHARSET_COLLATION = ; Empty as default, Gitea will try to find a case-sensitive collation. Don't change it unless you clearly know what you need.
; ;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; Postgres Configuration
; ;
; DB_TYPE = postgres
; HOST = 127.0.0.1:5432 ; can use socket e.g. /var/run/postgresql/
; NAME = gitea
; USER = root
; PASSWD =
; SCHEMA =
; SSL_MODE=disable ;either "disable" (default), "require", or "verify-full"
; ;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; SQLite Configuration
; ;
DB_TYPE = sqlite3
; defaults to data/gitea.db
PATH = /var/lib/gitea/data/gitea.db
; Query timeout defaults to: 500
SQLITE_TIMEOUT =
; defaults to sqlite database default (often DELETE), can be used to enable WAL mode. https://www.sqlite.org/pragma.html#pragma_journal_mode
SQLITE_JOURNAL_MODE =
HOST = 127.0.0.1:3306
NAME = gitea
USER = gitea
PASSWD =
SCHEMA =
SSL_MODE = disable
LOG_SQL = false
; ;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; MSSQL Configuration
; ;
; DB_TYPE = mssql
; HOST = 172.17.0.2:1433
; NAME = gitea
; USER = SA
; PASSWD = MwantsaSecurePassword1
; CHARSET_COLLATION = ; Empty as default, Gitea will try to find a case-sensitive collation. Don't change it unless you clearly know what you need.
; ;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; Other settings
; ;
; ; For iterate buffer, default is 50
; ITERATE_BUFFER_SIZE = 50
; ;
; ; Show the database generated SQL
; LOG_SQL = false
; ;
; ; Maximum number of DB Connect retries
; DB_RETRIES = 10
; ;
; ; Backoff time per DB retry (time.Duration)
; DB_RETRY_BACKOFF = 3s
; ;
; ; Max idle database connections on connection pool, default is 2
; MAX_IDLE_CONNS = 2
; ;
; ; Database connection max life time, default is 0 or 3s mysql (See #6804 & #7071 for reasoning)
; CONN_MAX_LIFETIME = 3s
; ;
; ; Database maximum number of open connections, default is 0 meaning no maximum
; MAX_OPEN_CONNS = 0
; ;
; ; Whether execute database models migrations automatically
; AUTO_MIGRATION = true
; ;
; ; Threshold value (in seconds) beyond which query execution time is logged as a warning in the xorm logger
; ;
; SLOW_QUERY_THRESHOLD = 5s
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[security]
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; Whether the installer is disabled (set to true to disable the installer)
INSTALL_LOCK = true
; ;
; ; Global secret key that will be used
; ; This key is VERY IMPORTANT. If you lose it, the data encrypted by it (like 2FA secret) can't be decrypted anymore.
SECRET_KEY =
; ;
; ; Alternative location to specify secret key, instead of this file; you cannot specify both this and SECRET_KEY, and must pick one
; ; This key is VERY IMPORTANT. If you lose it, the data encrypted by it (like 2FA secret) can't be decrypted anymore.
; SECRET_KEY_URI = file:/etc/gitea/secret_key
; ;
; ; Secret used to validate communication within Gitea binary.
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NzM4MjYzMzJ9.XxUurNQ5drHXpepExUTNEtYS4yLpTdac_fGOeL0eVco
PASSWORD_HASH_ALGO = pbkdf2
; ;
; ; Alternative location to specify internal token, instead of this file; you cannot specify both this and INTERNAL_TOKEN, and must pick one
; INTERNAL_TOKEN_URI = file:/etc/gitea/internal_token
; ;
; ; How long to remember that a user is logged in before requiring relogin (in days)
; LOGIN_REMEMBER_DAYS = 31
; ;
; ; Name of the cookie used to store the current username.
; COOKIE_USERNAME = gitea_awesome
; ;
; ; Name of cookie used to store authentication information.
; COOKIE_REMEMBER_NAME = gitea_incredible
; ;
; ; Reverse proxy authentication header name of user name, email, and full name
; REVERSE_PROXY_AUTHENTICATION_USER = X-WEBAUTH-USER
; REVERSE_PROXY_AUTHENTICATION_EMAIL = X-WEBAUTH-EMAIL
; REVERSE_PROXY_AUTHENTICATION_FULL_NAME = X-WEBAUTH-FULLNAME
; ;
; ; Interpret X-Forwarded-For header or the X-Real-IP header and set this as the remote IP for the request
; REVERSE_PROXY_LIMIT = 1
; ;
; ; List of IP addresses and networks separated by comma of trusted proxy servers. Use `*` to trust all.
; REVERSE_PROXY_TRUSTED_PROXIES = 127.0.0.0/8,::1/128
; ;
; ; The minimum password length for new Users
; MIN_PASSWORD_LENGTH = 8
; ;
; ; Set to true to allow users to import local server paths
; IMPORT_LOCAL_PATHS = false
; ;
; ; Set to false to allow users with git hook privileges to create custom git hooks.
; ; Custom git hooks can be used to perform arbitrary code execution on the host operating system.
; ; This enables the users to access and modify this config file and the Gitea database and interrupt the Gitea service.
; ; By modifying the Gitea database, users can gain Gitea administrator privileges.
; ; It also enables them to access other resources available to the user on the operating system that is running the Gitea instance and perform arbitrary actions in the name of the Gitea OS user.
; ; WARNING: This maybe harmful to you website or your operating system.
; ; WARNING: Setting this to true does not change existing hooks in git repos; adjust it before if necessary.
; DISABLE_GIT_HOOKS = true
; ;
; ; Set to true to disable webhooks feature.
; DISABLE_WEBHOOKS = false
; ;
; ; Set to false to allow pushes to gitea repositories despite having an incomplete environment - NOT RECOMMENDED
; ONLY_ALLOW_PUSH_IF_GITEA_ENVIRONMENT_SET = true
; ;
; ;Comma separated list of character classes required to pass minimum complexity.
; ;If left empty or no valid values are specified, the default is off (no checking)
; ;Classes include "lower,upper,digit,spec"
; PASSWORD_COMPLEXITY = off
; ;
; ; Password Hash algorithm, either "argon2", "pbkdf2", "scrypt" or "bcrypt"
; PASSWORD_HASH_ALGO = pbkdf2
; ;
; ; Set false to allow JavaScript to read CSRF cookie
; CSRF_COOKIE_HTTP_ONLY = true
; ;
; ; Validate against https://haveibeenpwned.com/Passwords to see if a password has been exposed
; PASSWORD_CHECK_PWN = false
; ;
; ; Cache successful token hashes. API tokens are stored in the DB as pbkdf2 hashes however, this means that there is a potentially significant hashing load when there are multiple API operations.
; ; This cache will store the successfully hashed tokens in a LRU cache as a balance between performance and security.
; SUCCESSFUL_TOKENS_CACHE_SIZE = 20
; ;
; ; Reject API tokens sent in URL query string (Accept Header-based API tokens only). This avoids security vulnerabilities
; ; stemming from cached/logged plain-text API tokens.
; ; In future releases, this will become the default behavior
; DISABLE_QUERY_AUTH_TOKEN = false
; ;
; ; On user registration, record the IP address and user agent of the user to help identify potential abuse.
; ; RECORD_USER_SIGNUP_METADATA = false
; ;
; ; Set the two-factor auth behavior.
; ; Set to "enforced", to force users to enroll into Two-Factor Authentication, users without 2FA have no access to repositories via API or web.
; TWO_FACTOR_AUTH =
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[camo]
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; At the moment we only support images
; ;
; ; if the camo is enabled
; ENABLED = false
; ; url to a camo image proxy, it **is required** if camo is enabled.
; SERVER_URL =
; ; HMAC to encode urls with, it **is required** if camo is enabled.
; HMAC_KEY =
; ; Set to true to use camo for https too lese only non https urls are proxyed
; ; ALLWAYS is deprecated and will be removed in the future
; ALWAYS = false
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[oauth2]
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; Enables OAuth2 provider
ENABLED = true
JWT_SECRET = tU0mvpYYFatn8ZJ45ZnuA4RLy6kogrerIblDbihu8oU
; ;
; ; Algorithm used to sign OAuth2 tokens. Valid values: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512, EdDSA
; JWT_SIGNING_ALGORITHM = RS256
; ;
; ; Private key file path used to sign OAuth2 tokens. The path is relative to APP_DATA_PATH.
; ; This setting is only needed if JWT_SIGNING_ALGORITHM is set to RS256, RS384, RS512, ES256, ES384 or ES512.
; ; The file must contain a RSA or ECDSA private key in the PKCS8 format. If no key exists a 4096 bit key will be created for you.
; JWT_SIGNING_PRIVATE_KEY_FILE = jwt/private.pem
; ;
; ; OAuth2 authentication secret for access and refresh tokens, change this yourself to a unique string. CLI generate option is helpful in this case. https://docs.gitea.io/en-us/command-line/#generate
; ; This setting is only needed if JWT_SIGNING_ALGORITHM is set to HS256, HS384 or HS512.
; JWT_SECRET =
; ;
; ; Alternative location to specify OAuth2 authentication secret. You cannot specify both this and JWT_SECRET, and must pick one
; JWT_SECRET_URI = file:/etc/gitea/oauth2_jwt_secret
; ;
; ; The "issuer" claim identifies the principal that issued the JWT.
; ; Gitea 1.25 makes it default to "ROOT_URL without the last slash" to follow the standard.
; ; If you have old logins from before 1.25, you may want to set it to the old (non-standard) value "ROOT_URL with the last slash".
; JWT_CLAIM_ISSUER =
; ;
; ; Lifetime of an OAuth2 access token in seconds
; ACCESS_TOKEN_EXPIRATION_TIME = 3600
; ;
; ; Lifetime of an OAuth2 refresh token in hours
; REFRESH_TOKEN_EXPIRATION_TIME = 730
; ;
; ; Check if refresh token got already used
; INVALIDATE_REFRESH_TOKENS = false
; ;
; ; Maximum length of oauth2 token/cookie stored on server
; MAX_TOKEN_LENGTH = 32767
; ;
; ; Pre-register OAuth2 applications for some universally useful services
; ; * https://github.com/hickford/git-credential-oauth
; ; * https://github.com/git-ecosystem/git-credential-manager
; ; * https://gitea.com/gitea/tea
; DEFAULT_APPLICATIONS = git-credential-oauth, git-credential-manager, tea
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[log]
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ; Root path for the log files - defaults to "{AppWorkPath}/log"
; ROOT_PATH =
; ;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ; Main Logger
; ;
; ; Either "console", "file" or "conn", default is "console"
; ; Use comma to separate multiple modes, e.g. "console, file"
MODE = console
; ;
; ; Either "Trace", "Debug", "Info", "Warn", "Error" or "None", default is "Info"
LEVEL = info
ROOT_PATH = /var/lib/gitea/log
; ;
; ; Print Stacktrace with logs (rarely helpful, do not set) Either "Trace", "Debug", "Info", "Warn", "Error", default is "None"
; STACKTRACE_LEVEL = None
; ;
; ; Buffer length of the channel, keep it as it is if you don't know what it is.
; BUFFER_LEN = 10000
; ;
; ; Sub logger modes, a single comma means use default MODE above, empty means disable it
; logger.access.MODE=
; logger.router.MODE=,
; logger.xorm.MODE=,
; ;
; ; Collect SSH logs (Creates log from ssh git request)
; ;
; ENABLE_SSH_LOG = false
; ;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; Access Logger (Creates log in NCSA common log format)
; ;
; ; Print request id which parsed from request headers in access log, when access log is enabled.
; ; * E.g:
; ; * In request Header: X-Request-ID: test-id-123
; ; * Configuration in app.ini: REQUEST_ID_HEADERS = X-Request-ID
; ; * Print in log: 127.0.0.1:58384 - - [14/Feb/2023:16:33:51 +0800] "test-id-123"
; ;
; ; If you configure more than one in the .ini file, it will match in the order of configuration,
; ; and the first match will be finally printed in the log.
; ; * E.g:
; ; * In request Header: X-Trace-ID: trace-id-1q2w3e4r
; ; * Configuration in app.ini: REQUEST_ID_HEADERS = X-Request-ID, X-Trace-ID, X-Req-ID
; ; * Print in log: 127.0.0.1:58384 - - [14/Feb/2023:16:33:51 +0800] "trace-id-1q2w3e4r"
; ;
; REQUEST_ID_HEADERS =
; ;
; ; Sets the template used to create the access log.
; ACCESS_LOG_TEMPLATE = {{.Ctx.RemoteHost}} - {{.Identity}} {{.Start.Format "[02/Jan/2006:15:04:05 -0700]" }} "{{.Ctx.Req.Method}} {{.Ctx.Req.URL.RequestURI}} {{.Ctx.Req.Proto}}" {{.ResponseWriter.Status}} {{.ResponseWriter.Size}} "{{.Ctx.Req.Referer}}" "{{.Ctx.Req.UserAgent}}"
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; Log modes (aka log writers)
; ;
; [log.%(WriterMode)]
; MODE=console/file/conn/...
; LEVEL=
; FLAGS = stdflags
; EXPRESSION =
; PREFIX =
; COLORIZE = false
; ;
; [log.console]
; STDERR = false
; ;
; [log.file]
; ; Set the file_name for the logger. If this is a relative path this will be relative to ROOT_PATH
; FILE_NAME =
; ; This enables automated log rotate(switch of following options), default is true
; LOG_ROTATE = true
; ; Max size shift of a single file, default is 28 means 1 << 28, 256MB
; MAX_SIZE_SHIFT = 28
; ; Segment log daily, default is true
; DAILY_ROTATE = true
; ; delete the log file after n days, default is 7
; MAX_DAYS = 7
; ; compress logs with gzip
; COMPRESS = true
; ; compression level see godoc for compress/gzip
; COMPRESSION_LEVEL = -1
; ;
; [log.conn]
; ; Reconnect host for every single message, default is false
; RECONNECT_ON_MSG = false
; ; Try to reconnect when connection is lost, default is false
; RECONNECT = false
; ; Either "tcp", "unix" or "udp", default is "tcp"
; PROTOCOL = tcp
; ; Host address
; ADDR =
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[git]
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;
; ; The path of git executable. If empty, Gitea searches through the PATH environment.
; PATH =
; ;
; ; The HOME directory for Git, defaults to "{APP_DATA_PATH}/home"
; HOME_PATH =
; ;
; ; Disables highlight of added and removed changes
; DISABLE_DIFF_HIGHLIGHT = false
; ;
; ; Max number of lines allowed in a single file in diff view
; MAX_GIT_DIFF_LINES = 1000
; ;
; ; Max number of allowed characters in a line in diff view
; MAX_GIT_DIFF_LINE_CHARACTERS = 5000
; ;
; ; Max number of files shown in diff view
; MAX_GIT_DIFF_FILES = 100
; ;
; ; Set the default commits range size
; COMMITS_RANGE_SIZE = 50
; ;
; ; Set the default branches range size
; BRANCHES_RANGE_SIZE = 20
; ;
; ; Arguments for command 'git gc', e.g. "--aggressive --auto"
; ; see more on http://git-scm.com/docs/git-gc/
; GC_ARGS =
; ;
; ; If use git wire protocol version 2 when git version >= 2.18, default is true, set to false when you always want git wire protocol version 1
; ; To enable this for Git over SSH when using a OpenSSH server, add `AcceptEnv GIT_PROTOCOL` to your sshd_config file.
; ENABLE_AUTO_GIT_WIRE_PROTOCOL = true
; ;
; ; Respond to pushes to a non-default branch with a URL for creating a Pull Request (if the repository has them enabled)
; PULL_REQUEST_PUSH_MESSAGE = true
; ;
; ; (Go-Git only) Don't cache objects greater than this in memory. (Set to 0 to disable.)
; LARGE_OBJECT_THRESHOLD = 1048576
; ; Set to true to forcibly set core.protectNTFS=false
; DISABLE_CORE_PROTECT_NTFS=false
; ; Disable the usage of using partial clones for git.
; DISABLE_PARTIAL_CLONE = false
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ; Git Operation timeout in seconds
; [git.timeout]
; DEFAULT = 360
; MIGRATE = 600
; MIRROR = 300
; CLONE = 300
; PULL = 300
; GC = 60
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ; Git config options
; ; This section only does "set" config, a removed config key from this section won't be removed from git config automatically. The format is `some.configKey = value`.
; [git.config]
; diff.algorithm = histogram
; core.logAllRefUpdates = true
; gc.reflogExpire = 90
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
[service]
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
DISABLE_REGISTRATION = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = true
ENABLE_CAPTCHA = false
REQUIRE_SIGNIN_VIEW = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = false
DEFAULT_ENABLE_TIMETRACKING = true
DEFAULT_USER_VISIBILITY = private
DEFAULT_USER_IS_RESTRICTED = true
NO_REPLY_ADDRESS = noreply.localhost
[repository]
ROOT = /var/lib/gitea/data/gitea-repositories
MAX_CREATION_LIMIT = 0
[lfs]
PATH = /var/lib/gitea/data/lfs
[mailer]
ENABLED = false
[openid]
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true
[cron.update_checker]
ENABLED = false
[session]
PROVIDER = file
[repository.pull-request]
DEFAULT_MERGE_STYLE = merge
[repository.signing]
DEFAULT_TRUST_MODEL = committer
[oauth2_client]
ENABLE_AUTO_REGISTRATION = true
ACCOUNT_LINKING = auto
OPENID_CONNECT_SCOPES = profile email

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-23 11:59
import modelcluster.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('home', '0019_coursepage_description'),
]
operations = [
migrations.AddField(
model_name='coursepage',
name='repository_url',
field=models.URLField(blank=True, null=True),
),
migrations.AlterField(
model_name='coursepage',
name='allowed_groups',
field=modelcluster.fields.ParentalManyToManyField(help_text="Additional groups that should have access to this course, e.g. Editors. NOTE: Users who purchase the course will be automatically added to a dedicated access group for this course, so you don't need to add that group here.", related_name='course_pages', to='auth.group'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-03-30 07:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0020_coursepage_repository_url_and_more'),
]
operations = [
migrations.AddField(
model_name='modulelessonpage',
name='create_gitea_repo',
field=models.BooleanField(default=False, help_text='If enabled, a Gitea repository will be automatically created for this module when the module is published.'),
),
migrations.AddField(
model_name='modulelessonpage',
name='gitea_repo_url',
field=models.URLField(blank=True, help_text="URL of the Gitea repository for this lesson (auto-generated if 'create_gitea_repo' is enabled)", null=True),
),
]

View File

@@ -0,0 +1,40 @@
# Generated by Django 6.0.3 on 2026-03-30 07:53
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0021_modulelessonpage_create_gitea_repo_and_more'),
('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'),
('wagtailimages', '0027_image_description'),
]
operations = [
migrations.CreateModel(
name='BlogIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='BlogPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('author', models.CharField(max_length=255)),
('body', wagtail.fields.StreamField([('heading', 0), ('paragraph', 1), ('image', 2)], block_lookup={0: ('wagtail.blocks.CharBlock', (), {'form_classname': 'title'}), 1: ('wagtail.blocks.RichTextBlock', (), {}), 2: ('wagtail.images.blocks.ImageBlock', [], {})})),
('image', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailimages.image')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-04-22 17:52
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0022_blogindexpage_blogpage'),
('wagtailcore', '0096_referenceindex_referenceindex_source_object_and_more'),
]
operations = [
migrations.CreateModel(
name='EventIndexPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
]

View File

@@ -1,15 +1,18 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.contrib.auth.models import Group, User
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group, User
from django.db import models from django.db import models
from django.forms import CheckboxSelectMultiple from django.forms import CheckboxSelectMultiple
from django.utils import timezone from django.utils import timezone
from modelcluster.contrib.taggit import ClusterTaggableManager from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase from taggit.models import TaggedItemBase
from wagtail.admin.panels import FieldPanel from wagtail import blocks
from wagtail.fields import RichTextField from wagtail.admin.panels import FieldPanel, InlinePanel
from wagtail.fields import RichTextField, StreamField
from wagtail.images.blocks import ImageBlock
from wagtail.models import Page from wagtail.models import Page
from wagtail.models.copying import ParentalManyToManyField from wagtail.models.copying import ParentalManyToManyField
from wagtail_color_panel.edit_handlers import NativeColorPanel from wagtail_color_panel.edit_handlers import NativeColorPanel
@@ -28,6 +31,15 @@ class HomePage(Page):
content_panels = Page.content_panels + ["body"] content_panels = Page.content_panels + ["body"]
class BlogIndexPage(Page):
subpage_types = ["home.BlogPage"]
def get_context(self, request):
context = super().get_context(request)
context["posts"] = self.get_children().live().order_by("-first_published_at")
return context
class CourseIndexPage(Page): class CourseIndexPage(Page):
subpage_types = ["home.CoursePage"] subpage_types = ["home.CoursePage"]
@@ -50,6 +62,35 @@ class CourseIndexPage(Page):
return context return context
class EventIndexPage(Page):
subpage_types = ["home.EventPage"]
class BlogPage(Page):
author = models.CharField(max_length=255)
image = models.ForeignKey(
"wagtailimages.Image",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
)
body = StreamField(
[
("heading", blocks.CharBlock(classname="title")),
("paragraph", blocks.RichTextBlock()),
("image", ImageBlock()),
]
)
content_panels = Page.content_panels + [
FieldPanel("author"),
FieldPanel("image"),
FieldPanel("body"),
]
parent_page_types = ["home.BlogIndexPage"]
class CoursePage(Page): class CoursePage(Page):
course_image = models.ForeignKey( course_image = models.ForeignKey(
"wagtailimages.Image", "wagtailimages.Image",
@@ -66,6 +107,11 @@ class CoursePage(Page):
help_text="Additional groups that should have access to this course, e.g. Editors. NOTE: Users who purchase the course will be automatically added to a dedicated access group for this course, so you don't need to add that group here.", help_text="Additional groups that should have access to this course, e.g. Editors. NOTE: Users who purchase the course will be automatically added to a dedicated access group for this course, so you don't need to add that group here.",
) )
repository_url = models.URLField(
null=True,
blank=True,
)
def _user_has_access(self, user): def _user_has_access(self, user):
if not user.is_authenticated: if not user.is_authenticated:
return False return False
@@ -75,7 +121,10 @@ class CoursePage(Page):
return True return True
return CoursePurchase.objects.filter( return CoursePurchase.objects.filter(
user=user, course=self, refunded=False user=user,
course=self,
refunded=False,
status=CoursePurchase.Status.PAID,
).exists() ).exists()
def _user_purchase_id(self, user): def _user_purchase_id(self, user):
@@ -83,7 +132,10 @@ class CoursePage(Page):
return None return None
purchase = CoursePurchase.objects.filter( purchase = CoursePurchase.objects.filter(
user=user, course=self, refunded=False user=user,
course=self,
refunded=False,
status=CoursePurchase.Status.PAID,
).first() ).first()
print(f"User {user} purchase for course {self}: {purchase}") print(f"User {user} purchase for course {self}: {purchase}")
return purchase.id if purchase else None return purchase.id if purchase else None
@@ -93,22 +145,26 @@ class CoursePage(Page):
if not user.is_authenticated: if not user.is_authenticated:
return False return False
obj, created = CoursePurchase.objects.get_or_create( obj, created = CoursePurchase.objects.get_or_create(
user=user, course=self, refunded=False user=user,
course=self,
refunded=False,
defaults={"status": CoursePurchase.Status.PAID},
) )
if obj.status != CoursePurchase.Status.PAID or obj.refunded:
obj.status = CoursePurchase.Status.PAID
obj.refunded = False
obj.save(update_fields=["status", "refunded"])
# Add user to dedicated access group for this course # Add user to dedicated access group for this course
group_name = f"course_{self.id}_access" group_name = f"course_{self.id}_access"
group, _ = Group.objects.get_or_create(name=group_name) group, _ = Group.objects.get_or_create(name=group_name)
user.groups.add(group) user.groups.add(group)
# Ensure allowed_groups only includes this access group
if not self.allowed_groups.filter(id=group.id).exists():
self.allowed_groups.add(group)
return created return created
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.id is not None:
group_name = f"course_{self.id}_access" group_name = f"course_{self.id}_access"
group, created = Group.objects.get_or_create(name=group_name) group, created = Group.objects.get_or_create(name=group_name)
if state := not self.allowed_groups.filter(id=group.id).exists(): if not self.allowed_groups.filter(id=group.id).exists():
print(state)
self.allowed_groups.add(group) self.allowed_groups.add(group)
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -123,8 +179,17 @@ class CoursePage(Page):
FieldPanel("course_image"), FieldPanel("course_image"),
FieldPanel("description"), FieldPanel("description"),
FieldPanel("body"), FieldPanel("body"),
InlinePanel(
"purchasable_products", label="Purchasable product", min_num=0, max_num=1
),
FieldPanel("allowed_groups", widget=CheckboxSelectMultiple), FieldPanel("allowed_groups", widget=CheckboxSelectMultiple),
FieldPanel(
"repository_url",
read_only=True,
heading="Repository URL (auto-generated)",
),
] ]
parent_page_types = ["home.CourseIndexPage"] parent_page_types = ["home.CourseIndexPage"]
subpage_types = ["home.CourseModulePage"] subpage_types = ["home.CourseModulePage"]
@@ -154,6 +219,23 @@ class CourseModulePage(Page):
class ModuleLessonPage(Page): class ModuleLessonPage(Page):
body = RichTextField(blank=True) body = RichTextField(blank=True)
create_gitea_repo = models.BooleanField(
default=False,
help_text="If enabled, a Gitea repository will be automatically created for this module when the module is published.",
)
gitea_repo_url = models.URLField(
null=True,
blank=True,
help_text="URL of the Gitea repository for this lesson (auto-generated if 'create_gitea_repo' is enabled)",
)
@property
def gitea_login_redirect_url(self):
gitea_root_url = getattr(settings, "GITEA_ROOT_URL")
if self.gitea_repo_url and gitea_root_url:
uri = str(self.gitea_repo_url).replace(gitea_root_url, "")
return f"{gitea_root_url}/user/login?redirect_to={uri}"
return None
@property @property
def module(self): def module(self):
@@ -170,7 +252,15 @@ class ModuleLessonPage(Page):
return f"{module.full_title} - {self.title}" return f"{module.full_title} - {self.title}"
return self.title return self.title
content_panels = Page.content_panels + ["body"] content_panels = Page.content_panels + [
FieldPanel("body"),
FieldPanel("create_gitea_repo"),
FieldPanel(
"gitea_repo_url",
read_only=True,
heading="Gitea Repository URL",
),
]
parent_page_types = ["home.CourseModulePage"] parent_page_types = ["home.CourseModulePage"]
@@ -251,6 +341,8 @@ class EventPage(Page):
help_text="Select users who will be listed as hosts of this event.", help_text="Select users who will be listed as hosts of this event.",
) )
parent_page_types = ["home.EventIndexPage"]
def get_context(self, request): def get_context(self, request):
context = super().get_context(request) context = super().get_context(request)
# Occurrence-specific context should be handled in views/templates # Occurrence-specific context should be handled in views/templates
@@ -261,15 +353,26 @@ class EventPage(Page):
Generate EventOccurrence objects for this event based on recurrence settings. Generate EventOccurrence objects for this event based on recurrence settings.
For endless recurrence, generate up to days_ahead into the future. For endless recurrence, generate up to days_ahead into the future.
""" """
from .event_occurrence import EventOccurrence
now = timezone.now() now = timezone.now()
if not self.live:
# if not live, no future occurrences should be generated
self.occurrences.filter(start__gt=now).delete()
return
if not self.recurrence_enabled: if not self.recurrence_enabled:
# if recurrence is not enabled, ensure there's at least one occurrence for the specified start/end # if recurrence is not enabled, ensure there's at least one occurrence for the specified start/end
# and delete any other occurrences that don't match the current start/end
if self.occurrences.exists(): if self.occurrences.exists():
occurrence = self.occurrences.first() occurrence = self.occurrences.first()
if occurrence.start != self.start or occurrence.end != self.end: if occurrence.start != self.start or occurrence.end != self.end:
occurrence.start = self.start occurrence.start = self.start
occurrence.end = self.end occurrence.end = self.end
occurrence.save(update_fields=["start", "end"]) occurrence.save(update_fields=["start", "end"])
self.occurrences.exclude(id=occurrence.id).delete()
else: else:
EventOccurrence.objects.create( EventOccurrence.objects.create(
event=self, start=self.start, end=self.end event=self, start=self.start, end=self.end

View File

@@ -1,39 +1,214 @@
import logging as lg
import os import os
import requests import requests
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from home.models.pages import CoursePage, ModuleLessonPage
@receiver(post_save, sender=User) GITEA_ORG_NAME = "Studio77"
def notify_external_service_on_signup(sender, instance, created, **kwargs):
pass logger = lg.getLogger(__name__)
# if created and not instance.is_staff:
# payload = {
# "user_id": instance.id, @receiver(post_save, sender=CoursePage)
# "username": f"KURSY-{instance.id}", def create_gitea_team_on_course_creation(sender, instance, created, **kwargs):
# "email": instance.email, if not instance.live:
# "full_name": f"{instance.first_name} {instance.last_name}".strip(), logger.debug(
# # "must_change_password": True, f"Course {instance.title} is not live, skipping Gitea team creation"
# # "password": instance.password, )
# "visibility": "private", return
# }
# api_url = getattr(settings, "GITEA_URL", None) course = instance
# if api_url: team_name = f"course-{course.id}"
# url = f"{api_url}/admin/users" api_url = getattr(settings, "GITEA_URL", None)
# try:
# response = requests.post( if not api_url:
# url, logger.debug("GITEA_URL is not set, skipping Gitea team creation")
# json=payload, return
# timeout=5,
# headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"}, # check if team already exists
# ) try:
# response.raise_for_status() response = requests.get(
# print(f"Successfully created Gitea account for {instance.email}") f"{api_url}/orgs/{GITEA_ORG_NAME}/teams",
# except Exception as e: timeout=5,
# print( headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
# f"Failed to create Gitea account for user {instance.email}: {e}\n{response.text}" )
# ) response.raise_for_status()
# raise e teams = response.json()
if any(team["name"] == team_name for team in teams):
logger.info(f"Gitea team {team_name} already exists, skipping creation")
return
except Exception as e:
logger.exception(
f"Failed to check existing Gitea teams: {e}\n{response.text}",
e,
)
return
url = f"{api_url}/orgs/{GITEA_ORG_NAME}/teams"
payload = {
"can_create_org_repo": False,
"description": f"Team for course {course.title}",
"includes_all_repositories": False,
"name": team_name,
"permission": "read",
"units": [
# "repo.actions",
"repo.code",
# "repo.issues",
# "repo.ext_issues",
# "repo.wiki",
# "repo.ext_wiki",
# "repo.pulls",
# "repo.releases",
# "repo.projects",
# "repo.ext_wiki",
],
}
try:
response = requests.post(
url,
json=payload,
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
response.raise_for_status()
logger.info(
f"Successfully created Gitea team `{team_name}` for course {course.title}"
)
except Exception as e:
logger.exception(
f"Failed to create Gitea team for course {course.title}: {e}\n{response.text}",
e,
)
@receiver(post_save, sender=ModuleLessonPage)
def create_gitea_repo_on_lesson_creation(
sender, instance: ModuleLessonPage, created, **kwargs
):
if not instance.live:
logger.debug(
f"Lesson {instance.title} is not live, skipping Gitea repository creation"
)
return
course = instance.module.course
repo_name = f"course-{course.id}-lesson-{instance.id}"
if not instance.create_gitea_repo:
logger.debug(
f"Lesson {instance.title} is not marked for Gitea repository creation, skipping"
)
return
if not course.live:
logger.debug(
f"Course {course.title} is not live, skipping Gitea repository creation for lesson {instance.title}"
)
return
api_url = getattr(settings, "GITEA_URL", None)
if not api_url:
logger.debug("GITEA_URL is not set, skipping Gitea repository creation")
return
def create_repository() -> None:
# check if repository already exists
try:
response = requests.get(
f"{api_url}/orgs/{GITEA_ORG_NAME}/repos",
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
response.raise_for_status()
repos = response.json()
if any(repo["name"] == repo_name for repo in repos):
logger.info(
f"Gitea repository {repo_name} already exists, skipping creation"
)
return
except Exception as e:
logger.exception(
f"Failed to check existing Gitea repositories: {e}\n{response.text}",
e,
)
return
# create lesson repository
url = f"{api_url}/orgs/{GITEA_ORG_NAME}/repos"
payload = {
"auto_init": True,
"default_branch": "main",
"description": f"{instance.module.course} {instance.module}: {instance.title}",
"name": repo_name,
"private": True,
}
try:
response = requests.post(
url,
json=payload,
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
response.raise_for_status()
repo_url = response.json().get("html_url")
instance.gitea_repo_url = repo_url
instance.save(update_fields=["gitea_repo_url"])
logger.info(
f"Successfully created Gitea repository for lesson {instance.title}: {repo_url}"
)
except Exception as e:
logger.exception(
f"Failed to create Gitea repository for lesson {instance.title}: {e}\n{response.text}",
e,
)
def add_repository_to_team() -> None:
# add repository to course team
team_name = f"course-{course.id}"
try:
response = requests.get(
f"{api_url}/orgs/{GITEA_ORG_NAME}/teams",
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
response.raise_for_status()
teams = response.json()
team = next((team for team in teams if team["name"] == team_name), None)
if not team:
logger.error(
f"Gitea team {team_name} not found when trying to add repository {repo_name}"
)
return
team_id = team["id"]
add_repo_url = (
f"{api_url}/teams/{team_id}/repos/{GITEA_ORG_NAME}/{repo_name}"
)
add_response = requests.put(
add_repo_url,
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
add_response.raise_for_status()
logger.info(
f"Successfully added repository {repo_name} to Gitea team {team_name}"
)
except Exception as e:
logger.exception(
f"Failed to add repository {repo_name} to Gitea team {team_name}: {e}\n{response.text}",
e,
)
create_repository()
add_repository_to_team()

View File

@@ -5,25 +5,88 @@
{% trans "Chat with" %} {{ chat_user.email }} {% trans "Chat with" %} {{ chat_user.email }}
{% endblock titletag %} {% endblock titletag %}
{% block extra_css %}
<style>
.admin-chat-messages {
list-style: none;
padding: 0;
margin: 1.5em 0;
max-height: 350px;
overflow-y: auto;
border-radius: 6px;
border: 1px solid #e2e2e2;
}
.admin-chat-messages li {
padding: 0.7em 1em;
border-bottom: 1px solid #ececec;
display: flex;
align-items: baseline;
font-size: 1.05em;
}
.admin-chat-messages li:last-child {
border-bottom: none;
}
.admin-chat-message-meta {
color: #888;
font-size: 0.92em;
margin-right: 0.5em;
white-space: nowrap;
}
.admin-chat-form {
display: flex;
gap: 0.5em;
margin-top: 1em;
}
.admin-chat-form input[type="text"] {
flex: 1;
padding: 0.5em;
border-radius: 4px;
border: 1px solid #ccc;
font-size: 1em;
}
.admin-chat-form button {
padding: 0.5em 1.2em;
border-radius: 4px;
border: none;
background: #0073e6;
color: #fff;
font-weight: bold;
cursor: pointer;
transition: background 0.2s;
}
.admin-chat-form button:hover {
background: #005bb5;
}
.admin-message-admin {
background: #334;
}
.admin-message-user {
}
</style>
{% endblock extra_css %}
{% block content %} {% block content %}
{% include "wagtailadmin/shared/header.html" with title="Chat" icon="mail" %} {% include "wagtailadmin/shared/header.html" with title="Chat" icon="mail" %}
<h1>{% trans "Admin Chat View" %}</h1> <div style="padding: 0 3em;">
<p>{% trans "This is the admin view of the chat. Here you can manage conversations and monitor user interactions." %}</p> <a href="{% url 'user_chat' %}" class="button button-secondary">&larr; {% trans "Back" %}</a>
<h1>{% trans "Chat with" %} <em>{{ chat_user.email }}</em></h1>
<ul> <ul class="admin-chat-messages">
{% for message in chat_messages %} {% for message in chat_messages %}
<li> <li class="{% if message.sender.id == chat_user.id %}admin-message-user{% else %}admin-message-admin{% endif %}">
<strong>{{ message.sender.email }}:</strong> {{ message.content }} <span class="admin-chat-message-meta">{{ message.timestamp|date:"Y-m-d H:i" }}</span>
<strong>{{ message.sender.email }}:&nbsp;</strong> {{ message.content }}
</li> </li>
{% empty %} {% empty %}
<li>{% trans "No messages found." %}</li> <li>{% trans "No messages found." %}</li>
{% endfor %} {% endfor %}
</ul> </ul>
<form action="/chat/send/{{ chat_user.id }}/" method="post"> <form action="/chat/send/{{ chat_user.id }}/" method="post" class="admin-chat-form">
{% csrf_token %} {% csrf_token %}
<input type="text" name="content" placeholder="{% trans "Type your message here..." %}" required> <input type="text" name="content" placeholder="{% trans "Type your message here..." %}" required>
<button type="submit">{% trans "Send" %}</button> <button type="submit">{% trans "Send" %}</button>
</form> </form>
</div>
{% endblock content %} {% endblock content %}

View File

@@ -6,21 +6,36 @@
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<h1>{% trans "Chat with Support" %}</h1> <h1 class="text-2xl font-bold mb-4 text-gray-800">{% trans "Chat with Support" %}</h1>
<p>{% trans "This is the user chat interface. Here you can communicate with our support team for assistance." %}</p> <p class="mb-6 text-gray-600">{% trans "Welcome! We're here to help. Type your message below to start a conversation with our support team." %}</p>
<ul> <ul class="flex flex-col gap-2 mb-6">
{% for message in chat_messages %} {% regroup chat_messages by timestamp.date as dated_messages %}
<li> {% for day in dated_messages %}
<strong>{{ message.sender.email }}:</strong> {{ message.content }} <li class="flex justify-center my-2">
<span class="px-4 py-1 bg-gray-100 text-gray-500 text-xs rounded-full shadow">{{ day.grouper|date:'l, d M Y' }}</span>
</li> </li>
{% for message in day.list %}
<li class="flex flex-col items-start {% if message.sender.id == user.id %}items-end{% endif %}">
<div class="max-w-xs px-4 py-2 rounded-2xl shadow text-sm
{% if message.sender.id == user.id %}
bg-blue-600 text-white rounded-br-none self-end
{% else %}
bg-gray-200 text-gray-900 rounded-bl-none self-start
{% endif %}">
<span class="block font-semibold text-xs mb-1 {% if message.sender.id == user.id %}text-blue-100{% else %}text-blue-700{% endif %}">{{ message.sender.email }}</span>
<span>{{ message.content }}</span>
<span class="block text-xs {% if message.sender.id == user.id %}text-blue-100{% else %}text-blue-700{% endif %} mt-1 text-right">{{ message.timestamp|date:'H:i' }}</span>
</div>
</li>
{% endfor %}
{% empty %} {% empty %}
<li>{% trans "No messages found." %}</li> <li class="text-gray-400">{% trans "No messages found." %}</li>
{% endfor %} {% endfor %}
</ul> </ul>
<form action="/chat/send/{{ user.id }}/" method="post"> <form action="/chat/send/{{ user.id }}/" method="post" class="flex gap-4">
{% csrf_token %} {% csrf_token %}
<textarea name="content" placeholder="{% trans "Type your message here..." %}"></textarea> <textarea rows=1 name="content" placeholder="{% trans "Type your message here..." %}" class="grow resize-y min-h-10 border border-gray-300 rounded-md p-2 focus:outline-none focus:ring-2 focus:ring-blue-400 text-gray-800"></textarea>
<button type="submit">{% trans "Send" %}</button> <button type="submit" class="self-center px-5 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition">{% trans "Send" %}</button>
</form> </form>
{% endblock content %} {% endblock content %}

View File

@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% load static i18n wagtailcore_tags wagtailimages_tags %}
{% block title %}{% trans "Blog" %}{% endblock title %}
{% block body_class %}template-blogindex{% endblock body_class %}
{% block content %}
<h1 class="text-3xl font-bold mb-6 text-gray-800 text-center">
{% trans "Blog" %}
</h1>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{% for post in posts %}
<a href="{{ post.url }}" class="block bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">
{% image post.specific.image original %}
<div class="p-4">
<div class="flex justify-between items-center mb-2 text-gray-500">
<div class="flex items-center gap-1">
<i class="fi fi-br-circle-user leading-0"></i>
<span class="text-sm">{{ post.specific.author }}</span>
</div>
<div class="flex items-center gap-1">
<i class="fi fi-br-calendar leading-0"></i>
<span class="text-sm">{{ post.first_published_at|date:"F j, Y | H:i" }}</span>
</div>
</div>
<h2 class="text-xl font-semibold mb-2">{{ post.specific.title }}</h2>
<p class="text-gray-600">{{ post.specific.description|truncatewords:20 }}</p>
</div>
</a>
{% empty %}
<p class="text-gray-600">{% trans "No blog posts available." %}</p>
{% endfor %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% load static i18n wagtailcore_tags wagtailimages_tags %}
{% block title %}{{ page.specific.title }} | {% trans "Blog" %}{% endblock title %}
{% block body_class %}template-blog{% endblock body_class %}
{% block content %}
<div class="max-w-3xl mx-auto py-12 px-4">
<a href="{{ page.get_parent.url }}" class="inline-flex items-center gap-1 text-gray-500 text-4xl mb-4 hover:text-gray-700 transition-colors duration-300">
<i class="fi fi-br-arrow-left leading-0"></i>
</a>
<h1 class="text-4xl font-bold mb-6 text-gray-800">
{{ page.specific.title }}
</h1>
<div class="flex items-center gap-4 mb-8 text-gray-500">
<div class="flex items-center gap-1">
<i class="fi fi-br-circle-user leading-0"></i>
<span class="text-sm">{{ page.specific.author }}</span>
</div>
<div class="flex items-center gap-1">
<i class="fi fi-br-calendar leading-0"></i>
<span class="text-sm">{{ page.first_published_at|date:"F j, Y | H:i" }}</span>
</div>
</div>
{% if page.specific.image %}
<div class="mb-8">
{% image page.specific.image original class="w-full max-h-100 object-contain rounded-lg" %}
</div>
{% endif %}
<article class="prose max-w-none">
{% for block in page.body %}
{% if block.block_type == 'heading' %}
<h2>
{{ block.value }}
</h2>
{% else %}
<section class="block-{{ block.block_type }}">
{% include_block block %}
</section>
{% endif %}
{% endfor %}
</article>
</div>
{% endblock content %}

View File

@@ -13,7 +13,9 @@
{% block content %} {% block content %}
<h2 class="not-prose text-xl mb-4 text-gray-700"> <h2 class="not-prose text-xl mb-4 text-gray-700">
<a href="{{ page.course.url }}" class="font-bold">{{ page.course.title }}</a> &raquo; {{ page.title }} <a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
&raquo; <a href="{{ page.course.url }}" class="font-bold hover:underline">{{ page.course.title }}</a>
&raquo; {{ page.title }}
</h2> </h2>
{{ page.body|richtext }} {{ page.body|richtext }}

View File

@@ -12,8 +12,9 @@
{% block content_class %}prose{% endblock content_class %} {% block content_class %}prose{% endblock content_class %}
{% block content %} {% block content %}
<h1 class="not-prose text-3xl mb-4 text-gray-700 font-bold"> <h1 class="not-prose text-3xl mb-4 text-gray-700">
{{ page.title }} <a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
&raquo; {{ page.title }}
</h1> </h1>
{% if page.course_image %} {% if page.course_image %}
@@ -48,10 +49,22 @@
<a href="{% url 'account_signup' %}?next={{ request.path }}" class="mt-4 inline-block bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition ml-2">{% trans "Sign Up" %}</a> <a href="{% url 'account_signup' %}?next={{ request.path }}" class="mt-4 inline-block bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition ml-2">{% trans "Sign Up" %}</a>
</div> </div>
{% elif not page.purchasable_products.exists %}
<div class="not-prose mt-8 p-4 bg-gray-100 border-l-4 border-gray-500 text-gray-700">
<p>{% trans "Course is not yet available for purchase. Please check back later." %}</p>
</div>
{% else %} {% else %}
<div class="not-prose mt-8 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700"> <div class="not-prose mt-8 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700">
<p>{% trans "You don't have access to this course. Please purchase it to view the modules." %}</p> <p>{% trans "You don't have access to this course. Please purchase it to view the modules." %}</p>
<a href="{% url 'mock_purchase_course' course_id=page.id %}" class="mt-4 inline-block bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">{% trans "Purchase Course" %}</a> <h2 class="not-prose text-2xl mt-4 text-gray-700 font-semibold">
{{ page.purchasable_products.first.price | floatformat:2 }} {{ page.purchasable_products.first.currency | upper }}
</h2>
<form method="post" action="{% url 'create_checkout_session' purchasable_id=page.purchasable_products.first.id %}">
{% csrf_token %}
<button type="submit" class="mt-2 inline-block bg-green-600 text-white px-4 py-2 rounded cursor-pointer hover:bg-green-700 transition">
{% trans "Purchase Course" %}
</button>
</form>
</div> </div>
{% endif %} {% endif %}
{% endblock content %} {% endblock content %}

View File

@@ -13,13 +13,21 @@
{% block content %} {% block content %}
<h2 class="not-prose text-xl mb-4 text-gray-700"> <h2 class="not-prose text-xl mb-4 text-gray-700">
<a href="{{ page.module.course.url }}" class="font-bold">{{ page.module.course.title }}</a> <a href="{% slugurl 'courses' %}" class="font-bold hover:underline">{% trans "Courses" %}</a>
&raquo; &raquo; <a href="{{ page.module.course.url }}" class="font-bold hover:underline">{{ page.module.course.title }}</a>
<a href="{{ page.module.url }}" class="font-bold">{{ page.module.title }}</a> &raquo; <a href="{{ page.module.url }}" class="font-bold hover:underline">{{ page.module.title }}</a>
&raquo; &raquo; <span class="text-gray-500">{{ page.title }}</span>
<span class="text-gray-500">{{ page.title }}</span>
</h2> </h2>
{% if page.create_gitea_repo and page.gitea_repo_url %}
<p class="not-prose mb-6 text-gray-600 text-lg flex items-center gap-1">
<i class="fi fi-br-code-window leading-0"></i>
<a href="{{ page.gitea_login_redirect_url }}" class="hover:underline" target="_blank" rel="noopener noreferrer">
{% trans "View code on Gitea" %}
</a>
</p>
{% endif %}
{{ page.body|richtext }} {{ page.body|richtext }}
{% endblock content %} {% endblock content %}

View File

@@ -1,10 +1,12 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.shortcuts import redirect, render from django.shortcuts import redirect, render, get_object_or_404
from home.models import ChatMessage from home.models import ChatMessage
from purchase.models import PurchasableProduct
from django.views.decorators.http import require_http_methods
# Chat admin + user views (restored)
@login_required @login_required
def admin_chat_dashboard(request): def admin_chat_dashboard(request):
chats = ChatMessage.get_all_user_senders() chats = ChatMessage.get_all_user_senders()
@@ -14,7 +16,6 @@ def admin_chat_dashboard(request):
@login_required @login_required
def admin_chat(request, user_id): def admin_chat(request, user_id):
chat_user = User.objects.filter(id=user_id, is_staff=False).first() chat_user = User.objects.filter(id=user_id, is_staff=False).first()
print(chat_user)
chat_messages = ChatMessage.get_support_chat(chat_user) chat_messages = ChatMessage.get_support_chat(chat_user)
return render( return render(
request, request,

View File

@@ -47,7 +47,7 @@ def register_code_block_feature(features):
@hooks.register("register_admin_urls") @hooks.register("register_admin_urls")
def register_admin_chat_dashboard_url(): def register_admin_urls():
return [ return [
path("chat/", views.admin_chat_dashboard, name="admin_chat_dashboard"), path("chat/", views.admin_chat_dashboard, name="admin_chat_dashboard"),
path( path(

View File

@@ -1,13 +1,63 @@
import logging as lg
import os
import requests
from django import forms from django import forms
from django.conf import settings
from django.core.handlers.wsgi import WSGIRequest
logger = lg.getLogger(__name__)
def create_gitea_account(user, password):
payload = {
"user_id": user.id,
"username": f"studio77-{user.id}",
"email": user.email,
"full_name": f"{user.first_name} {user.last_name}".strip(),
"password": password,
"must_change_password": False,
"visibility": "private",
}
api_url = getattr(settings, "GITEA_URL", None)
if api_url:
url = f"{api_url}/admin/users"
try:
response = requests.post(
url,
json=payload,
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
response.raise_for_status()
logger.info(f"Successfully created Gitea account for {user.email}")
except requests.exceptions.HTTPError as http_err:
status_code = http_err.response.status_code
if status_code == 422:
logger.warning(
f"Gitea account for user {user.email} already exists. Skipping creation."
)
else:
logger.error(
f"HTTP error occurred while creating Gitea account for user {user.email}: {http_err}\n{response.text}"
)
raise http_err
except Exception as e:
logger.error(
f"Failed to create Gitea account for user {user.email}: {e}\n{response.text}"
)
raise e
class SignUpForm(forms.Form): class SignUpForm(forms.Form):
first_name = forms.CharField(max_length=60, required=True, label="First Name") first_name = forms.CharField(max_length=60, required=True, label="First Name")
last_name = forms.CharField(max_length=60, required=True, label="Last Name") last_name = forms.CharField(max_length=60, required=True, label="Last Name")
def signup(self, request, user): def signup(self, request: WSGIRequest, user):
user.first_name = self.cleaned_data["first_name"] user.first_name = self.cleaned_data["first_name"].strip().title()
user.last_name = self.cleaned_data["last_name"] user.last_name = self.cleaned_data["last_name"].strip().title()
user.save() user.save()
return user # gitea account creation
password = request.POST.get("password1")
create_gitea_account(user, password)

View File

@@ -6,6 +6,6 @@ class CustomOAuth2Validator(OAuth2Validator):
print("get_additional_claims", request.user) print("get_additional_claims", request.user)
return { return {
"name": " ".join([request.user.first_name, request.user.last_name]), "name": " ".join([request.user.first_name, request.user.last_name]),
"preferred_username": f"studio77-{request.user.username}", "preferred_username": f"studio77-{request.user.id}",
"email": request.user.email, "email": request.user.email,
} }

View File

@@ -16,6 +16,7 @@ import os
from pathlib import Path from pathlib import Path
import dotenv import dotenv
import logging as lg
PROJECT_DIR = Path(__file__).resolve().parent.parent PROJECT_DIR = Path(__file__).resolve().parent.parent
BASE_DIR = PROJECT_DIR.parent BASE_DIR = PROJECT_DIR.parent
@@ -161,7 +162,7 @@ OAUTH2_PROVIDER = {
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3", "NAME": BASE_DIR / "db" / "db.sqlite3",
} }
} }
@@ -241,6 +242,43 @@ STORAGES = {
DATA_UPLOAD_MAX_NUMBER_FIELDS = 10_000 DATA_UPLOAD_MAX_NUMBER_FIELDS = 10_000
# Logging
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{asctime} : {levelname} : {filename}:{lineno} : {name} :: {message}",
"style": "{",
},
"simple": {"format": "{asctime} : {levelname} :: {message}", "style": "{"},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"loggers": {
"django": {
"handlers": ["console"],
"level": "INFO",
"propagate": True,
},
"django.request": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"home": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": True,
},
},
}
# Wagtail settings # Wagtail settings
WAGTAIL_SITE_NAME = "kursy" WAGTAIL_SITE_NAME = "kursy"
@@ -276,5 +314,16 @@ WAGTAILDOCS_EXTENSIONS = [
TAILWIND_APP_NAME = "theme" TAILWIND_APP_NAME = "theme"
SITE_URL = "http://localhost:8000"
STRIPE_DEFAULT_CURRENCY = "pln"
STRIPE_SUCCESS_URL = f"{SITE_URL}/purchase/success/"
# Gitea API # Gitea API
GITEA_URL = "http://localhost:3000/api/v1" GITEA_ROOT_URL = "http://localhost:3000"
GITEA_URL = f"{GITEA_ROOT_URL}/api/v1"
lg.basicConfig(
level=lg.DEBUG,
format="%(asctime)s : %(levelname)s : %(filename)s:%(lineno)d : %(name)s :: %(message)s",
)

View File

@@ -5,7 +5,9 @@
<nav class="flex items-center gap-4"> <nav class="flex items-center gap-4">
<a class="text-xl font-bold" href="/">{{ current_site.site_name }}</a> <a class="text-xl font-bold" href="/">{{ current_site.site_name }}</a>
<a href="{% slugurl 'courses' %}" class="hover:underline">{% trans "Courses" %}</a> <a href="{% slugurl 'courses' %}" class="hover:underline">{% trans "Courses" %}</a>
<a href="{% slugurl 'blog' %}" class="hover:underline">{% trans "Blog" %}</a>
<a href="{% url 'calendar' %}" class="hover:underline">{% trans "Calendar" %}</a> <a href="{% url 'calendar' %}" class="hover:underline">{% trans "Calendar" %}</a>
<a href="{% url 'user_chat' %}" class="hover:underline">{% trans "Help" %}</a>
</nav> </nav>
<nav class="flex items-center gap-4"> <nav class="flex items-center gap-4">

View File

@@ -22,7 +22,7 @@ urlpatterns = [
path("i18n/", include("django.conf.urls.i18n")), path("i18n/", include("django.conf.urls.i18n")),
path("oauth2/", include("oauth2_provider.urls", namespace="oauth2_provider")), path("oauth2/", include("oauth2_provider.urls", namespace="oauth2_provider")),
path("", include("home.urls")), path("", include("home.urls")),
path("", include("purchase.urls")), path("purchase/", include("purchase.urls")),
path("calendar/", views.calendar, name="calendar"), path("calendar/", views.calendar, name="calendar"),
# TODO: move occurrence related urls to home app # TODO: move occurrence related urls to home app
path( path(

View File

@@ -0,0 +1,26 @@
# Generated by Django 6.0.4 on 2026-05-18 14:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('purchase', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='PurchasableProduct',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('price_cents', models.PositiveIntegerField(help_text='Price in cents')),
('currency', models.CharField(default='usd', max_length=10)),
('stripe_product_id', models.CharField(blank=True, max_length=255, null=True)),
('stripe_price_id', models.CharField(blank=True, max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-05-18 14:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('purchase', '0002_purchasableproduct'),
]
operations = [
migrations.AlterField(
model_name='purchasableproduct',
name='currency',
field=models.CharField(default='pln', max_length=10),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 6.0.4 on 2026-05-18 15:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0023_eventindexpage'),
('purchase', '0003_alter_purchasableproduct_currency'),
]
operations = [
migrations.AddField(
model_name='purchasableproduct',
name='course',
field=models.OneToOneField(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='purchasable_product', to='home.coursepage'),
preserve_default=False,
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 6.0.4 on 2026-05-18 15:35
import django.db.models.deletion
import modelcluster.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0023_eventindexpage'),
('purchase', '0004_purchasableproduct_course'),
]
operations = [
migrations.AlterModelOptions(
name='purchasableproduct',
options={'ordering': ['sort_order']},
),
migrations.AddField(
model_name='purchasableproduct',
name='sort_order',
field=models.IntegerField(blank=True, editable=False, null=True),
),
migrations.AlterField(
model_name='purchasableproduct',
name='course',
field=modelcluster.fields.ParentalKey(blank=True, help_text='Link this PurchasableProduct to a CoursePage for inline editing.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='purchasable_products', to='home.coursepage'),
),
migrations.AlterField(
model_name='purchasableproduct',
name='name',
field=models.CharField(blank=True, max_length=255),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.4 on 2026-05-18 15:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('purchase', '0005_alter_purchasableproduct_options_and_more'),
]
operations = [
migrations.AddField(
model_name='purchasableproduct',
name='stripe_payment_url',
field=models.URLField(blank=True, help_text='Stripe Checkout URL for this product (optional, can be set via admin or programmatically)', null=True),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 6.0.4 on 2026-05-18 16:18
import django.db.models.deletion
import modelcluster.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0023_eventindexpage'),
('purchase', '0006_purchasableproduct_stripe_payment_url'),
]
operations = [
migrations.AddField(
model_name='coursepurchase',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('pending', 'Pending'), ('paid', 'Paid'), ('refunded', 'Refunded'), ('failed', 'Failed')], default='initiated', max_length=20),
),
migrations.AddField(
model_name='coursepurchase',
name='stripe_checkout_session_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='purchasableproduct',
name='course',
field=modelcluster.fields.ParentalKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='purchasable_products', to='home.coursepage'),
),
migrations.AlterField(
model_name='purchasableproduct',
name='stripe_payment_url',
field=models.URLField(blank=True, help_text='Stripe Checkout URL for this product', null=True),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 6.0.4 on 2026-05-20 17:09
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('purchase', '0007_coursepurchase_status_and_more'),
]
operations = [
migrations.RemoveField(
model_name='purchasableproduct',
name='stripe_payment_url',
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.4 on 2026-05-20 17:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('purchase', '0008_remove_purchasableproduct_stripe_payment_url'),
]
operations = [
migrations.AddField(
model_name='coursepurchase',
name='stripe_charge_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='coursepurchase',
name='stripe_payment_intent_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@@ -1,23 +1,523 @@
import logging as lg
import os
import requests
import stripe
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.db import models from django.db import models
from wagtail.admin.panels import FieldPanel
from modelcluster.fields import ParentalKey
from wagtail.models import Orderable
GITEA_ORG_NAME = "Studio77"
logger = lg.getLogger(__name__)
class CoursePurchase(models.Model): class CoursePurchase(models.Model):
class Status(models.TextChoices):
INITIATED = "initiated"
PENDING = "pending"
PAID = "paid"
REFUNDED = "refunded"
FAILED = "failed"
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
course = models.ForeignKey("home.CoursePage", on_delete=models.CASCADE) course = models.ForeignKey("home.CoursePage", on_delete=models.CASCADE)
purchased_at = models.DateTimeField(auto_now_add=True) purchased_at = models.DateTimeField(auto_now_add=True)
refunded = models.BooleanField(default=False) refunded = models.BooleanField(default=False)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.INITIATED,
)
stripe_checkout_session_id = models.CharField(max_length=255, blank=True, null=True)
# Stripe identifiers to help reconcile refunds coming from webhooks or admin actions
stripe_payment_intent_id = models.CharField(max_length=255, blank=True, null=True)
stripe_charge_id = models.CharField(max_length=255, blank=True, null=True)
def mock_refund(self): def mock_refund(self):
"""Legacy helper used in dev: mark purchase refunded locally and perform cleanup.
Prefer using `refund_via_stripe` to perform an actual Stripe refund when appropriate.
"""
# If we have Stripe identifiers it's better to actually issue a refund via Stripe
if self.stripe_charge_id or self.stripe_payment_intent_id:
try:
self.refund_via_stripe()
return True
except Exception:
# Fallback to local refund if Stripe refund cannot be performed
pass
self.refunded = True self.refunded = True
self.status = CoursePurchase.Status.REFUNDED
self.save() self.save()
def refund_via_stripe(self, amount=None, reason=None):
"""Initiate a refund in Stripe for this purchase and mark it refunded locally.
- amount: integer in cents (optional) to perform a partial refund
- reason: optional string ("duplicate", "fraud", "requested_by_customer")
This method is idempotent: calling it for an already-refunded purchase will be
a no-op.
"""
# If already refunded, do nothing
if self.refunded:
return None
stripe_api_key = getattr(
settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY")
)
if not stripe_api_key:
# Can't call Stripe; mark refunded locally (useful for local testing)
self.refunded = True
self.status = CoursePurchase.Status.REFUNDED
self.save()
return None
import stripe as _stripe
_stripe.api_key = stripe_api_key
# Determine what identifier to use for refunding: prefer charge id if present,
# otherwise use payment_intent. Stripe accepts either when creating a refund.
refund_kwargs = {}
if amount is not None:
refund_kwargs["amount"] = int(amount)
if reason is not None:
refund_kwargs["reason"] = reason
try:
if self.stripe_charge_id:
refund = _stripe.Refund.create(
charge=self.stripe_charge_id, **refund_kwargs
)
elif self.stripe_payment_intent_id:
refund = _stripe.Refund.create(
payment_intent=self.stripe_payment_intent_id, **refund_kwargs
)
else:
# As a last resort, try to lookup the PaymentIntent from the Checkout Session
if self.stripe_checkout_session_id:
session = _stripe.checkout.Session.retrieve(
self.stripe_checkout_session_id
)
payment_intent = session.get("payment_intent")
if payment_intent:
refund = _stripe.Refund.create(
payment_intent=payment_intent, **refund_kwargs
)
else:
raise RuntimeError(
"No Stripe identifiers available to perform refund"
)
else:
raise RuntimeError(
"No Stripe identifiers available to perform refund"
)
# On success, mark refunded locally and perform cleanup (remove group, gitea team)
self.refunded = True
self.status = CoursePurchase.Status.REFUNDED
# Try to persist charge/payment intent ids if Stripe returned them
try:
charge_id = getattr(refund, "charge", None)
if charge_id and not self.stripe_charge_id:
self.stripe_charge_id = charge_id
except Exception:
pass
try:
# Some Refund objects include payment_intent
pi = getattr(refund, "payment_intent", None)
if pi and not self.stripe_payment_intent_id:
self.stripe_payment_intent_id = pi
except Exception:
pass
# Save and trigger model save logic (which will remove Gitea/team membership)
self.save()
return refund
except Exception as e:
logger.exception(
f"Failed to initiate Stripe refund for CoursePurchase {self.id}: {e}"
)
raise
def _get_gitea_team_id(self, team_name):
api_url = getattr(settings, "GITEA_URL", None)
if not api_url:
logger.debug("GITEA_URL is not set, skipping Gitea team assignment")
return None
try:
response = requests.get(
f"{api_url}/orgs/{GITEA_ORG_NAME}/teams",
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
response.raise_for_status()
teams = response.json()
team = next((team for team in teams if team["name"] == team_name), None)
return team["id"] if team else None
except Exception as e:
logger.exception(
f"Failed to check existing Gitea teams: {e}\n{getattr(response, 'text', '')}",
e,
)
return None
def add_to_gitea_team(self):
course = self.course
user = self.user
team_name = f"course-{course.id}"
api_url = getattr(settings, "GITEA_URL", None)
if not api_url:
logger.debug("GITEA_URL is not set, skipping Gitea team assignment")
return
team_id = self._get_gitea_team_id(team_name)
if not team_id:
logger.warning(
f"Gitea team {team_name} not found for course {course.title}"
)
return
url = f"{api_url}/teams/{team_id}/members/studio77-{user.id}"
try:
response = requests.put(
url,
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
if response.status_code == 204:
logger.info(
f"Successfully added user {user.email} to Gitea team {team_name}"
)
else:
response.raise_for_status()
except Exception as e:
logger.error(
f"Failed to add user {user.email} to Gitea team {team_name}: {e}\n{getattr(response, 'text', '')}"
)
def remove_from_gitea_team(self):
course = self.course
user = self.user
team_name = f"course-{course.id}"
api_url = getattr(settings, "GITEA_URL", None)
if not api_url:
logger.debug("GITEA_URL is not set, skipping Gitea team removal")
return
team_id = self._get_gitea_team_id(team_name)
if not team_id:
logger.warning(
f"Gitea team {team_name} not found for course {course.title}"
)
return
url = f"{api_url}/teams/{team_id}/members/studio77-{user.id}"
try:
response = requests.delete(
url,
timeout=5,
headers={"Authorization": f"token {os.getenv('GITEA_API_TOKEN')}"},
)
if response.status_code == 204:
logger.info(
f"Successfully removed user {user.email} from Gitea team {team_name}"
)
else:
response.raise_for_status()
except Exception as e:
logger.error(
f"Failed to remove user {user.email} from Gitea team {team_name}: {e}\n{getattr(response, 'text', '')}"
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
previous_state = None
if self.pk:
previous_state = (
self.__class__.objects.filter(pk=self.pk)
.values("status", "refunded")
.first()
)
super().save(*args, **kwargs) super().save(*args, **kwargs)
group_name = f"course_{self.course.id}_access" group_name = f"course_{self.course.id}_access"
group, _ = Group.objects.get_or_create(name=group_name) group, _ = Group.objects.get_or_create(name=group_name)
if self.refunded:
print(f"Removing user {self.user} from group {group_name} due to refund") print(
f"Saving CoursePurchase for user {self.user} and course {self.course.title}, refunded={self.refunded}"
)
should_grant_access = (
self.status == CoursePurchase.Status.PAID and not self.refunded
)
had_granted_access = (
bool(previous_state)
and previous_state["status"] == CoursePurchase.Status.PAID
and not previous_state["refunded"]
)
if should_grant_access:
logger.debug(
f"Adding user {self.user} to group {group_name} for course {self.course.title}"
)
self.add_to_gitea_team()
self.user.groups.add(group)
else:
if had_granted_access:
print(
f"Removing user {self.user} from group {group_name} due to status {self.status}"
)
self.remove_from_gitea_team()
self.user.groups.remove(group) self.user.groups.remove(group)
class PurchasableProduct(Orderable, models.Model):
"""A product that can be purchased. When created it will create a Stripe Product and Price.
On delete it will try to deactivate the Price and delete the Product in Stripe.
The code is defensive: if STRIPE_API_KEY is not configured the model will still work locally.
"""
name = models.CharField(max_length=255, blank=True)
description = models.TextField(blank=True)
price_cents = models.PositiveIntegerField(help_text="Price in cents")
currency = models.CharField(
max_length=10, default=getattr(settings, "STRIPE_DEFAULT_CURRENCY", "pln")
)
stripe_product_id = models.CharField(max_length=255, blank=True, null=True)
stripe_price_id = models.CharField(max_length=255, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
course = ParentalKey(
"home.CoursePage",
on_delete=models.CASCADE,
related_name="purchasable_products",
null=True,
blank=True,
)
panels = [
FieldPanel("price_cents"),
FieldPanel("currency"),
FieldPanel("stripe_product_id", read_only=True),
FieldPanel("stripe_price_id", read_only=True),
FieldPanel("stripe_payment_url", read_only=True),
]
@property
def price(self):
return self.price_cents / 100
def __str__(self):
return f"{self.name} ({self.price_cents / 100:.2f} {self.currency.upper()})"
def save(self, *args, **kwargs):
"""Save locally first to ensure we have a PK, then create or update Stripe product/price if needed.
Behavior:
- On create: create Stripe Product and Price (if STRIPE_SECRET_KEY is set).
- On update:
- If name or description changed -> update Stripe Product.
- If price_cents or currency changed -> create a new Stripe Price and deactivate the old one, then update stripe_price_id.
- If STRIPE_SECRET_KEY is not configured the model will still work locally.
"""
# Capture whether this is a new object and the previous state (if any)
is_new = self.pk is None
previous = None
if not is_new:
try:
previous = self.__class__.objects.get(pk=self.pk)
except self.__class__.DoesNotExist:
previous = None
# Get name, description and image from the linked course if not set explicitly on the product
if self.course:
course_name = self.course.title
course_description = self.course.description or ""
if not self.name:
self.name = course_name
if not self.description:
self.description = course_description
# Persist local changes first so we have an id
super().save(*args, **kwargs)
stripe_api_key = getattr(
settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY")
)
if not stripe_api_key:
logger.debug(
"STRIPE_SECRET_KEY not set, skipping Stripe product/price creation/update"
)
return
stripe.api_key = stripe_api_key
try:
changed_fields = []
# Create Stripe Product if missing
if not self.stripe_product_id:
prod = stripe.Product.create(
name=self.name,
description=self.description or None,
metadata={"local_id": str(self.id)},
)
self.stripe_product_id = prod.id
changed_fields.append("stripe_product_id")
logger.info(
f"Created Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
)
# If we don't have a price id, create one
if not self.stripe_price_id:
price = stripe.Price.create(
product=self.stripe_product_id,
unit_amount=self.price_cents,
currency=self.currency.lower(),
)
self.stripe_price_id = price.id
changed_fields.append("stripe_price_id")
logger.info(
f"Created Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
)
# Create Stripe Payment Link if missing or if price changed
if not self.stripe_payment_url and self.stripe_price_id:
try:
payment_link = stripe.PaymentLink.create(
line_items=[{"price": self.stripe_price_id, "quantity": 1}],
after_completion={
"type": "redirect",
"redirect": {
"url": getattr(
settings,
"STRIPE_SUCCESS_URL",
"https://example.com/success",
)
},
},
)
self.stripe_payment_url = payment_link.url
changed_fields.append("stripe_payment_url")
logger.info(
f"Created Stripe payment link {self.stripe_payment_url} for PurchasableProduct {self.id}"
)
except Exception as e:
logger.exception(
f"Failed to create Stripe payment link for PurchasableProduct {self.id}: {e}"
)
# If this is an update (we had previous state) perform updates
if previous:
# Update product metadata/name/description if they changed
try:
if (previous.name != self.name) or (
previous.description != self.description
):
stripe.Product.modify(
self.stripe_product_id,
name=self.name,
description=self.description or None,
)
logger.info(
f"Updated Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
)
except Exception:
logger.exception(
f"Failed to update Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
)
# If price or currency changed, create a new Price and deactivate the old one
try:
prev_currency = (previous.currency or "").lower()
curr_currency = (self.currency or "").lower()
if (previous.price_cents != self.price_cents) or (
prev_currency != curr_currency
):
# Create new price for the same product
new_price = stripe.Price.create(
product=self.stripe_product_id,
unit_amount=self.price_cents,
currency=self.currency.lower(),
)
# Attempt to deactivate the old price, but don't fail the whole operation if it fails
try:
if self.stripe_price_id:
stripe.Price.modify(self.stripe_price_id, active=False)
logger.info(
f"Deactivated old Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
)
except Exception:
logger.exception(
f"Failed to deactivate old Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
)
# Switch to the new price id and persist it
self.stripe_price_id = new_price.id
if "stripe_price_id" not in changed_fields:
changed_fields.append("stripe_price_id")
logger.info(
f"Created new Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
)
except Exception:
logger.exception(
f"Failed to create or switch Stripe price for PurchasableProduct {self.id}"
)
# Persist any changed stripe ids without triggering further Stripe operations
if changed_fields:
super().save(update_fields=changed_fields)
except Exception as e:
logger.exception(
f"Failed to create/update Stripe product/price for PurchasableProduct {self.id}: {e}"
)
def delete(self, *args, **kwargs):
"""Try to clean up Stripe resources when the product is deleted locally."""
stripe_api_key = getattr(
settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY")
)
if not stripe_api_key:
logger.debug(
"STRIPE_SECRET_KEY not set, skipping Stripe product/price cleanup"
)
return super().delete(*args, **kwargs)
stripe.api_key = stripe_api_key
# Attempt to deactivate price and delete product. Be tolerant of failures.
if self.stripe_price_id:
try:
stripe.Price.modify(self.stripe_price_id, active=False)
logger.info(
f"Deactivated Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
)
except Exception:
logger.exception(
f"Failed to deactivate Stripe price {self.stripe_price_id} for PurchasableProduct {self.id}"
)
if self.stripe_product_id:
try:
stripe.Product.modify(self.stripe_product_id, active=False)
logger.info(
f"Deactivated Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
)
except Exception:
logger.exception(
f"Failed to deactivate Stripe product {self.stripe_product_id} for PurchasableProduct {self.id}"
)
return super().delete(*args, **kwargs)

View File

@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block content %}
<div class="container mt-5">
<div class="card">
<div class="text-center">
<h1 class="text-9xl">BRAWO KURWA!!!!</h1>
<p>WYDAŁEŚ PIENIĄDZE NA JAKIEŚ TOTALNE GÓWNO!!!!</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,3 +1,133 @@
from django.test import TestCase from unittest import mock
# Create your tests here. from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse
from wagtail.models import Page
from home.models.pages import CoursePage
from purchase.models import CoursePurchase, PurchasableProduct
@override_settings(
STRIPE_SECRET_KEY=None,
STRIPE_WEBHOOK_SECRET="test",
GITEA_URL=None,
)
class PurchaseStatusTests(TestCase):
"""Regression tests for purchase status tracking."""
def setUp(self):
self.user = get_user_model().objects.create_user(
username="alice",
email="alice@example.com",
password="pass12345",
)
root_page = Page.get_first_root_node()
self.course = CoursePage(title="Django Course", slug="django-course")
root_page.add_child(instance=self.course)
self.product = PurchasableProduct.objects.create(
name="Django Course",
course=self.course,
price_cents=1000,
currency="pln",
)
def _create_purchase(self, status, session_id="cs_test", refunded=False):
return CoursePurchase.objects.create(
user=self.user,
course=self.course,
status=status,
refunded=refunded,
stripe_checkout_session_id=session_id,
)
def _post_stripe_event(self, event):
with mock.patch(
"purchase.views.stripe.Webhook.construct_event", return_value=event
):
response = self.client.post(
reverse("stripe-webhook"),
data="{}",
content_type="application/json",
HTTP_STRIPE_SIGNATURE="test-signature",
)
self.assertEqual(response.status_code, 200)
def test_access_depends_on_paid_status(self):
purchase = self._create_purchase(CoursePurchase.Status.PENDING)
self.assertFalse(self.course._user_has_access(self.user))
purchase.status = CoursePurchase.Status.FAILED
purchase.save(update_fields=["status"])
self.assertFalse(self.course._user_has_access(self.user))
purchase.status = CoursePurchase.Status.PAID
purchase.refunded = False
purchase.save(update_fields=["status", "refunded"])
self.assertTrue(self.course._user_has_access(self.user))
def test_checkout_session_completed_unpaid_stays_pending(self):
purchase = self._create_purchase(
CoursePurchase.Status.PENDING, session_id="cs_completed"
)
self._post_stripe_event(
{
"type": "checkout.session.completed",
"data": {
"object": {
"id": "cs_completed",
"payment_status": "unpaid",
}
},
}
)
purchase.refresh_from_db()
self.assertEqual(purchase.status, CoursePurchase.Status.PENDING)
def test_async_payment_failed_marks_purchase_failed(self):
purchase = self._create_purchase(
CoursePurchase.Status.PENDING, session_id="cs_async_failed"
)
self._post_stripe_event(
{
"type": "checkout.session.async_payment_failed",
"data": {"object": {"id": "cs_async_failed"}},
}
)
purchase.refresh_from_db()
self.assertEqual(purchase.status, CoursePurchase.Status.FAILED)
self.assertFalse(self.course._user_has_access(self.user))
def test_payment_intent_failed_marks_purchase_failed_with_metadata_fallback(self):
purchase = self._create_purchase(
CoursePurchase.Status.PENDING, session_id="cs_pi_failed"
)
self._post_stripe_event(
{
"type": "payment_intent.payment_failed",
"data": {
"object": {
"id": "pi_failed",
"metadata": {
"user_id": str(self.user.id),
"client_reference_id": str(self.user.id),
"purchasable_id": str(self.product.id),
},
"receipt_email": self.user.email,
"charges": {"data": []},
}
},
}
)
purchase.refresh_from_db()
self.assertEqual(purchase.status, CoursePurchase.Status.FAILED)
self.assertFalse(self.course._user_has_access(self.user))

View File

@@ -1,4 +1,5 @@
from django.urls import path from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from . import views from . import views
@@ -13,4 +14,11 @@ urlpatterns = [
views.mock_refund_purchase, views.mock_refund_purchase,
name="mock_refund_purchase", name="mock_refund_purchase",
), ),
path("stripe/webhook/", csrf_exempt(views.stripe_webhook), name="stripe-webhook"),
path(
"stripe/checkout/<int:purchasable_id>/",
views.create_checkout_session,
name="create_checkout_session",
),
path("success/", views.purchase_success, name="purchase_success"),
] ]

View File

@@ -1,8 +1,20 @@
import json
import logging
import os
import stripe
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError, transaction
from django.http import HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from home.models import CoursePage from home.models import CoursePage
from purchase.models import CoursePurchase from purchase.models import CoursePurchase, PurchasableProduct
logger = logging.getLogger(__name__)
def mock_purchase_course(request, course_id): def mock_purchase_course(request, course_id):
@@ -19,3 +31,598 @@ def mock_refund_purchase(request, purchase_id):
purchase.mock_refund() purchase.mock_refund()
return redirect(purchase.course.url) return redirect(purchase.course.url)
def purchase_success(request):
return render(request, "success.html")
@login_required
@require_POST
def create_checkout_session(request, purchasable_id):
"""Create a Stripe Checkout Session for the given PurchasableProduct and include the local user id in metadata.
This view requires an authenticated POST request and will redirect the user to Stripe Checkout.
It will also create or get a pending CoursePurchase linked to the checkout session.
"""
stripe.api_key = getattr(
settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY")
)
try:
purch = PurchasableProduct.objects.get(pk=purchasable_id)
except PurchasableProduct.DoesNotExist:
return HttpResponse("Purchasable product not found", status=404)
if not purch.stripe_price_id:
return HttpResponse("Product not configured for Stripe", status=400)
try:
session = stripe.checkout.Session.create(
payment_method_types=["card", "blik", "p24"],
line_items=[{"price": purch.stripe_price_id, "quantity": 1}],
customer_email=request.user.email or None,
mode="payment",
client_reference_id=str(request.user.id),
metadata={"user_id": str(request.user.id), "purchasable_id": str(purch.id)},
payment_intent_data={
"metadata": {
"user_id": str(request.user.id),
"purchasable_id": str(purch.id),
"client_reference_id": str(request.user.id),
}
},
success_url=getattr(
settings, "STRIPE_SUCCESS_URL", "https://example.com/success"
),
cancel_url=getattr(
settings, "STRIPE_CANCEL_URL", "https://example.com/cancel"
),
)
# Create or get a pending CoursePurchase tied to this session (idempotent)
try:
with transaction.atomic():
purchase, created = CoursePurchase.objects.get_or_create(
user=request.user,
course=purch.course,
refunded=False,
defaults={
"status": CoursePurchase.Status.PENDING,
"stripe_checkout_session_id": session.id,
"stripe_payment_intent_id": session.payment_intent,
},
)
except IntegrityError:
# Race: another worker created the purchase concurrently. Re-fetch.
purchase = CoursePurchase.objects.filter(
user=request.user, course=purch.course
).first()
if not purchase:
purchase = CoursePurchase.objects.filter(
stripe_checkout_session_id=session.id
).first()
if purchase:
if purchase.status == CoursePurchase.Status.PAID and not purchase.refunded:
return redirect(purch.course.url)
update_fields = []
if purchase.status != CoursePurchase.Status.PENDING:
purchase.status = CoursePurchase.Status.PENDING
update_fields.append("status")
if purchase.refunded:
purchase.refunded = False
update_fields.append("refunded")
if purchase.stripe_checkout_session_id != session.id:
purchase.stripe_checkout_session_id = session.id
update_fields.append("stripe_checkout_session_id")
if purchase.stripe_payment_intent_id is not None:
purchase.stripe_payment_intent_id = None
update_fields.append("stripe_payment_intent_id")
if purchase.stripe_charge_id is not None:
purchase.stripe_charge_id = None
update_fields.append("stripe_charge_id")
if update_fields:
purchase.save(update_fields=update_fields)
# Redirect to Stripe Checkout
return redirect(session.url)
except Exception as e:
logger.exception(
f"Failed to create checkout session for purchasable {purchasable_id}: {e}"
)
raise e
@csrf_exempt
def stripe_webhook(request):
stripe.api_key = getattr(
settings, "STRIPE_SECRET_KEY", os.getenv("STRIPE_SECRET_KEY")
)
webhook_secret = getattr(
settings, "STRIPE_WEBHOOK_SECRET", os.getenv("STRIPE_WEBHOOK_SECRET")
)
payload = request.body
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE")
event = None
try:
if webhook_secret:
event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
else:
# fallback: parse payload as JSON
event = stripe.Event.construct_from(
json.loads(payload.decode("utf-8")), stripe.api_key
)
except ValueError as e:
logger.error(f"Invalid payload: {e}")
raise e
except stripe.error.SignatureVerificationError as e:
logger.error(f"Webhook signature verification failed: {e}")
raise e
# Helper to safely index Stripe objects / nested dict-like objects using [] semantics
def _s(obj, key):
try:
return obj[key]
except Exception:
return None
# Handle the event
logger.info(f"Received Stripe event: {event['type']}")
# Helper to mark a purchase refunded by Stripe identifiers
def _mark_purchase_refunded(purchase):
if not purchase:
return
purchase.refunded = True
purchase.status = CoursePurchase.Status.REFUNDED
# Save normally so our CoursePurchase.save() logic runs (this will remove groups/gitea membership)
purchase.save()
logger.info(
f"Marked CoursePurchase {purchase.id} as REFUNDED due to Stripe event"
)
def _find_purchase_from_payment_intent(pi):
pi_id = _s(pi, "id")
metadata = _s(pi, "metadata") or {}
purchase = None
session_id = (
_s(metadata, "checkout_session")
or _s(metadata, "session_id")
or _s(metadata, "stripe_checkout_session_id")
)
if session_id:
purchase = CoursePurchase.objects.filter(
stripe_checkout_session_id=session_id
).first()
if not purchase and pi_id:
purchase = CoursePurchase.objects.filter(
stripe_payment_intent_id=pi_id
).first()
from django.contrib.auth import get_user_model
User = get_user_model()
user = None
user_id = _s(metadata, "user_id")
if user_id:
try:
user = User.objects.get(pk=int(user_id))
except Exception:
user = None
if not user:
client_ref = _s(metadata, "client_reference_id") or _s(
metadata, "client_id"
)
if client_ref:
try:
user = User.objects.get(pk=int(client_ref))
except Exception:
user = None
if not user:
email = _s(pi, "receipt_email")
if not email:
charges = _s(_s(pi, "charges") or {}, "data") or []
if charges:
billing = _s(charges[0], "billing_details") or {}
email = _s(billing, "email")
if email:
user = User.objects.filter(email=email).first()
course = None
purchasable_id = _s(metadata, "purchasable_id")
if purchasable_id:
try:
purch = PurchasableProduct.objects.get(pk=int(purchasable_id))
course = purch.course
except Exception:
course = None
if not purchase and user and course:
purchase = (
CoursePurchase.objects.filter(user=user, course=course, refunded=False)
.order_by("-id")
.first()
)
return purchase
# Handle checkout session completion: ensure purchase exists and store PaymentIntent / Charge ids
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
session_id = session["id"]
payment_status = _s(session, "payment_status")
logger.info(f"Checkout session completed: {session}")
# First try to find existing CoursePurchase by session id
purchase = CoursePurchase.objects.filter(
stripe_checkout_session_id=session_id
).first()
if purchase:
updated = False
if (
payment_status == "paid"
and purchase.status != CoursePurchase.Status.PAID
):
purchase.status = CoursePurchase.Status.PAID
updated = True
if purchase.refunded:
purchase.refunded = False
updated = True
if updated:
purchase.save(update_fields=["status", "refunded"])
if payment_status == "paid":
logger.info(f"Marked CoursePurchase {purchase.id} as PAID.")
else:
logger.info(
"Processed checkout.session.completed for CoursePurchase %s without changing status (payment_status=%s).",
purchase.id,
payment_status,
)
else:
# No existing purchase — attempt to create one by inspecting the Stripe session
try:
session_obj = stripe.checkout.Session.retrieve(
session_id,
expand=[
"line_items.data.price.product",
"customer",
"customer_details",
],
)
# Determine user from session metadata, client_reference_id or customer details
from django.contrib.auth import get_user_model
User = get_user_model()
user = None
metadata = _s(session_obj, "metadata") or {}
# Prefer explicit user_id in metadata
user_id = _s(metadata, "user_id")
if user_id:
try:
user = User.objects.get(pk=int(user_id))
except Exception:
user = None
# Fallback to client_reference_id (often used to pass local user id)
if not user:
client_ref = _s(session_obj, "client_reference_id")
if client_ref:
try:
user = User.objects.get(pk=int(client_ref))
except Exception:
user = None
# Fallback to customer email
if not user:
email = None
cust_details = _s(session_obj, "customer_details") or {}
email = _s(cust_details, "email") or _s(
session_obj, "customer_email"
)
if not email:
customer = _s(session_obj, "customer")
if isinstance(customer, dict):
email = _s(customer, "email")
if email:
user = User.objects.filter(email=email).first()
# Find the purchasable product from line items using product.metadata.local_id
course = None
line_items = _s(_s(session_obj, "line_items") or {}, "data") or []
for item in line_items:
price = _s(item, "price") or {}
product = _s(price, "product")
if isinstance(product, dict):
local_id = _s(_s(product, "metadata") or {}, "local_id")
if local_id:
try:
purch = PurchasableProduct.objects.get(pk=int(local_id))
course = purch.course
break
except Exception:
continue
if user and course:
try:
with transaction.atomic():
purchase, created = CoursePurchase.objects.get_or_create(
user=user,
course=course,
defaults={
"status": (
CoursePurchase.Status.PAID
if payment_status == "paid"
else CoursePurchase.Status.PENDING
),
"refunded": False,
"stripe_checkout_session_id": session_id,
},
)
except IntegrityError:
# Race: another worker created the purchase concurrently. Re-fetch.
purchase = CoursePurchase.objects.filter(
user=user, course=course
).first()
if not purchase:
purchase = CoursePurchase.objects.filter(
stripe_checkout_session_id=session_id
).first()
if purchase:
# Ensure fields are up to date idempotently
update_fields = []
if (
payment_status == "paid"
and purchase.status != CoursePurchase.Status.PAID
):
purchase.status = CoursePurchase.Status.PAID
update_fields.append("status")
if purchase.refunded:
purchase.refunded = False
update_fields.append("refunded")
if not purchase.stripe_checkout_session_id:
purchase.stripe_checkout_session_id = session_id
update_fields.append("stripe_checkout_session_id")
# Store PaymentIntent and Charge (if present) for later refund handling
pi = _s(session_obj, "payment_intent")
if pi and not purchase.stripe_payment_intent_id:
purchase.stripe_payment_intent_id = pi
update_fields.append("stripe_payment_intent_id")
# Try to obtain charge id from PaymentIntent
try:
pi_obj = stripe.PaymentIntent.retrieve(
pi, expand=["charges.data"]
)
charges = _s(_s(pi_obj, "charges") or {}, "data") or []
if charges:
charge_id = _s(charges[0], "id")
if charge_id and not purchase.stripe_charge_id:
purchase.stripe_charge_id = charge_id
update_fields.append("stripe_charge_id")
except Exception:
logger.exception(
"Failed to retrieve PaymentIntent/charges for storing charge id"
)
if update_fields:
purchase.save(update_fields=update_fields)
logger.info(
"Updated CoursePurchase %s (fields: %s).",
purchase.id,
update_fields,
)
else:
logger.info(
"CoursePurchase %s already up-to-date.", purchase.id
)
if created:
logger.info(
"Created CoursePurchase %s for user %s and course %s.",
purchase.id,
user,
course,
)
else:
logger.warning(
"Could not create CoursePurchase for session %s: user=%s, course=%s",
session_id,
user,
course,
)
else:
logger.warning(
"Could not create CoursePurchase for session %s: user=%s, course=%s",
session_id,
user,
course,
)
except Exception as e:
logger.exception(
f"Failed to create CoursePurchase for session {session_id}: {e}"
)
# Refund-related events: charge.refunded, refund.created, refund.updated
elif event["type"] in ("charge.refunded", "refund.created", "refund.updated"):
obj = event["data"]["object"]
# obj may be a Charge (for charge.refunded) or a Refund (for refund.*)
obj_object = _s(obj, "object")
if obj_object == "charge":
charge_id = _s(obj, "id")
else:
charge_id = _s(obj, "charge")
payment_intent_id = _s(obj, "payment_intent")
# Try to find the CoursePurchase by charge id or payment_intent
purchase = None
if charge_id:
purchase = CoursePurchase.objects.filter(stripe_charge_id=charge_id).first()
if not purchase and payment_intent_id:
purchase = CoursePurchase.objects.filter(
stripe_payment_intent_id=payment_intent_id
).first()
if purchase:
_mark_purchase_refunded(purchase)
else:
logger.warning(
"Received refund webhook but could not find CoursePurchase for charge=%s pi=%s",
charge_id,
payment_intent_id,
)
elif event["type"] == "checkout.session.expired":
session = event["data"]["object"]
session_id = session["id"]
try:
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
purchase.status = CoursePurchase.Status.FAILED
purchase.save(update_fields=["status"])
logger.info(f"Marked CoursePurchase {purchase.id} as FAILED (expired).")
except CoursePurchase.DoesNotExist:
logger.warning(f"No CoursePurchase found for expired session {session_id}")
elif event["type"] == "checkout.session.async_payment_failed":
session = event["data"]["object"]
session_id = session["id"]
try:
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
purchase.status = CoursePurchase.Status.FAILED
purchase.save(update_fields=["status"])
logger.info(
f"Marked CoursePurchase {purchase.id} as FAILED (async payment failed)."
)
except CoursePurchase.DoesNotExist:
logger.warning(f"No CoursePurchase found for failed session {session_id}")
elif event["type"] == "checkout.session.async_payment_succeeded":
session = event["data"]["object"]
session_id = session["id"]
try:
purchase = CoursePurchase.objects.get(stripe_checkout_session_id=session_id)
purchase.status = CoursePurchase.Status.PAID
purchase.refunded = False
purchase.save(update_fields=["status", "refunded"])
logger.info(
f"Marked CoursePurchase {purchase.id} as PAID (async succeeded)."
)
except CoursePurchase.DoesNotExist:
logger.warning(
f"No CoursePurchase found for async succeeded session {session_id}"
)
elif event["type"] == "payment_intent.created":
pi = event["data"]["object"]
pi_id = pi["id"]
purchase = None
try:
# Try to find CoursePurchase by checkout session id present in PaymentIntent metadata
metadata = _s(pi, "metadata") or {}
session_id = (
_s(metadata, "checkout_session")
or _s(metadata, "session_id")
or _s(metadata, "stripe_checkout_session_id")
)
if session_id:
purchase = CoursePurchase.objects.filter(
stripe_checkout_session_id=session_id
).first()
# Fallback: try client_reference_id (may be copied into metadata)
if not purchase:
client_ref = _s(metadata, "client_reference_id") or _s(
metadata, "client_id"
)
if client_ref:
try:
purchase = (
CoursePurchase.objects.filter(
user__pk=int(client_ref), refunded=False
)
.order_by("-id")
.first()
)
except Exception:
purchase = None
# Fallback: try receipt email / billing details
if not purchase:
email = _s(pi, "receipt_email")
if not email:
charges = _s(_s(pi, "charges") or {}, "data") or []
if charges:
billing = _s(charges[0], "billing_details") or {}
email = _s(billing, "email")
if email:
purchase = (
CoursePurchase.objects.filter(user__email=email, refunded=False)
.order_by("-id")
.first()
)
if purchase:
update_fields = []
if not purchase.stripe_payment_intent_id and pi_id:
purchase.stripe_payment_intent_id = pi_id
update_fields.append("stripe_payment_intent_id")
# If charge id is present in the PaymentIntent payload, store it too
charges = _s(_s(pi, "charges") or {}, "data") or []
if charges:
charge_id = _s(charges[0], "id")
if charge_id and not purchase.stripe_charge_id:
purchase.stripe_charge_id = charge_id
update_fields.append("stripe_charge_id")
if update_fields:
purchase.save(update_fields=update_fields)
logger.info(
"Updated CoursePurchase %s with fields %s from payment_intent.created",
purchase.id,
update_fields,
)
else:
logger.debug(
"payment_intent.created: CoursePurchase %s already has payment fields set",
purchase.id,
)
else:
logger.info(
"payment_intent.created: no matching CoursePurchase for pi=%s",
pi_id,
)
logger.debug("payment_intent.created payload: %s", pi)
except Exception:
logger.exception("Failed processing payment_intent.created")
elif event["type"] == "payment_intent.payment_failed":
pi = event["data"]["object"]
pi_id = pi["id"]
try:
purchase = _find_purchase_from_payment_intent(pi)
if purchase:
if purchase.status != CoursePurchase.Status.FAILED:
purchase.status = CoursePurchase.Status.FAILED
purchase.save(update_fields=["status"])
logger.info(
f"Marked CoursePurchase {purchase.id} as FAILED (payment intent failed)."
)
else:
raise CoursePurchase.DoesNotExist
except CoursePurchase.DoesNotExist:
logger.warning(f"No CoursePurchase found for failed payment intent {pi_id}")
return HttpResponse(status=200)

View File

@@ -11,8 +11,12 @@ dependencies = [
"django-oauth-toolkit>=3.2.0", "django-oauth-toolkit>=3.2.0",
"django-tailwind>=4.4.2", "django-tailwind>=4.4.2",
"django-widget-tweaks>=1.5.1", "django-widget-tweaks>=1.5.1",
"gunicorn>=25.3.0",
"python-dotenv>=1.2.2", "python-dotenv>=1.2.2",
"slippers>=0.6.2", "slippers>=0.6.2",
"stripe>=15.1.0",
"uvicorn>=0.42.0",
"uvicorn-worker>=0.4.0",
"wagtail==7.3rc1", "wagtail==7.3rc1",
"wagtail-color-panel>=1.7.1", "wagtail-color-panel>=1.7.1",
"wagtailmedia>=0.17.2", "wagtailmedia>=0.17.2",

367
uv.lock generated
View File

@@ -57,11 +57,11 @@ wheels = [
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.2.25" version = "2026.4.22"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
] ]
[[package]] [[package]]
@@ -99,39 +99,55 @@ wheels = [
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.5" version = "3.4.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
{ url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
{ url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
{ url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
{ url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
] ]
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.1" version = "8.3.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
] ]
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" } sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
wheels = [ wheels = [
{ 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" }, { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
] ]
[[package]] [[package]]
@@ -164,55 +180,55 @@ wheels = [
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "46.0.5" version = "46.0.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
] ]
[[package]] [[package]]
@@ -226,29 +242,29 @@ wheels = [
[[package]] [[package]]
name = "django" name = "django"
version = "6.0.3" version = "6.0.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "asgiref" }, { name = "asgiref" },
{ name = "sqlparse" }, { name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" } sdist = { url = "https://files.pythonhosted.org/packages/60/b9/4155091ad1788b38563bd77a7258c0834e8c12a7f56f6975deaf54f8b61d/django-6.0.4.tar.gz", hash = "sha256:8cfa2572b3f2768b2e84983cf3c4811877a01edb64e817986ec5d60751c113ac", size = 10907407, upload-time = "2026-04-07T13:55:44.961Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" }, { url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" },
] ]
[[package]] [[package]]
name = "django-allauth" name = "django-allauth"
version = "65.15.0" version = "65.16.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "asgiref" }, { name = "asgiref" },
{ name = "django" }, { name = "django" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/84/c1/d3385f4c3169c1d6eea3c63aed0f36af51478c1d72e46db12bb1a08f8034/django_allauth-65.15.0.tar.gz", hash = "sha256:b404d48cf0c3ee14dacc834c541f30adedba2ff1c433980ecc494d6cb0b395a8", size = 2215709, upload-time = "2026-03-09T13:51:28.675Z" } sdist = { url = "https://files.pythonhosted.org/packages/3d/df/357187dfff18c7783e4911827a6c69437e290d7259a32a99c23fcd85997f/django_allauth-65.16.1.tar.gz", hash = "sha256:4425ac3088541c4c54983e16e08f6e3eb9f438dc1b1009534fa51c8bb739ed31", size = 2232835, upload-time = "2026-04-17T18:53:59.475Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/b8/c8411339171bd8bc075c09ef190fb42195e9a2149e5c5026e094fe62fce0/django_allauth-65.15.0-py3-none-any.whl", hash = "sha256:ad9fc49c49a9368eaa5bb95456b76e2a4f377b3c6862ee8443507816578c098d", size = 2022994, upload-time = "2026-03-09T13:51:19.711Z" }, { url = "https://files.pythonhosted.org/packages/ad/58/d95b6c3088d83697bfd93782ee57bc6a6462e41eb19121a947b8a015396a/django_allauth-65.16.1-py3-none-any.whl", hash = "sha256:e49df24056bf37c44e56aaad1e51f78994b7d175bc3476d65e8f8f58390a8ce8", size = 2051868, upload-time = "2026-04-17T18:54:12.032Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -337,15 +353,15 @@ wheels = [
[[package]] [[package]]
name = "django-stubs-ext" name = "django-stubs-ext"
version = "5.2.9" version = "6.0.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/55/03/9c2be939490d2282328db4611bc5956899f5ff7eabc3e88bd4b964a87373/django_stubs_ext-5.2.9.tar.gz", hash = "sha256:6db4054d1580657b979b7d391474719f1a978773e66c7070a5e246cd445a25a9", size = 6497, upload-time = "2026-01-20T23:58:59.462Z" } sdist = { url = "https://files.pythonhosted.org/packages/fb/e6/5dcdaa785ec3eed5fc196c7e68fb7ad9d9fe6d5acccea4690e65f2546417/django_stubs_ext-6.0.3.tar.gz", hash = "sha256:3307d42132bc295d5744de6276bc5fdf6896efc70f891e21c0ae8bdf529d2762", size = 6663, upload-time = "2026-04-18T15:10:53.667Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/f7/0d5f7d7e76fe972d9f560f687fdc0cab4db9e1624fd90728ca29b4ed7a63/django_stubs_ext-5.2.9-py3-none-any.whl", hash = "sha256:230c51575551b0165be40177f0f6805f1e3ebf799b835c85f5d64c371ca6cf71", size = 9974, upload-time = "2026-01-20T23:58:58.438Z" }, { url = "https://files.pythonhosted.org/packages/10/fa/0a3a05c29d6295dbd52fa3cb4047a95de11ba4f2696072d6f3f2c1e6f370/django_stubs_ext-6.0.3-py3-none-any.whl", hash = "sha256:9e4105955419ae310d7da9cfd808e039d4dae3092c628f021057bb4f2c237f8f", size = 10354, upload-time = "2026-04-18T15:10:52.395Z" },
] ]
[[package]] [[package]]
@@ -413,14 +429,14 @@ wheels = [
[[package]] [[package]]
name = "djangorestframework" name = "djangorestframework"
version = "3.16.1" version = "3.17.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } sdist = { url = "https://files.pythonhosted.org/packages/ca/d7/c016e69fac19ff8afdc89db9d31d9ae43ae031e4d1993b20aca179b8301a/djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5", size = 905742, upload-time = "2026-03-24T16:58:33.705Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, { url = "https://files.pythonhosted.org/packages/5a/e1/2c516bdc83652b1a60c6119366ac2c0607b479ed05cd6093f916ca8928f8/djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457", size = 898844, upload-time = "2026-03-24T16:58:31.845Z" },
] ]
[[package]] [[package]]
@@ -451,12 +467,33 @@ wheels = [
] ]
[[package]] [[package]]
name = "idna" name = "gunicorn"
version = "3.11" version = "25.3.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "idna"
version = "3.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
] ]
[[package]] [[package]]
@@ -473,15 +510,15 @@ wheels = [
[[package]] [[package]]
name = "jwcrypto" name = "jwcrypto"
version = "1.5.6" version = "1.5.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e1/db/870e5d5fb311b0bcf049630b5ba3abca2d339fd5e13ba175b4c13b456d08/jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039", size = 87168, upload-time = "2024-03-06T19:58:31.831Z" } sdist = { url = "https://files.pythonhosted.org/packages/8c/90/f065668004d22715c1940d6e88e4c3afc8ee16d5664e4478d2c8fd23a250/jwcrypto-1.5.7.tar.gz", hash = "sha256:70204d7cca406eda8c82352e3c41ba2d946610dafd19e54403f0a1f4f18633c6", size = 89535, upload-time = "2026-04-07T00:35:36.116Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/58/4a1880ea64032185e9ae9f63940c9327c6952d5584ea544a8f66972f2fda/jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789", size = 92520, upload-time = "2024-03-06T19:58:29.765Z" }, { url = "https://files.pythonhosted.org/packages/72/24/fb7da4d6613de7001feaf540d4b5969c6b5a1c42839043b0196cb13aa057/jwcrypto-1.5.7-py3-none-any.whl", hash = "sha256:729463fefe28b6de5cf1ebfda3e94f1a1b41d2799148ef98a01cb9678ebe2bb0", size = 94799, upload-time = "2026-04-07T00:35:35.085Z" },
] ]
[[package]] [[package]]
@@ -497,8 +534,12 @@ dependencies = [
{ name = "django-oauth-toolkit" }, { name = "django-oauth-toolkit" },
{ name = "django-tailwind" }, { name = "django-tailwind" },
{ name = "django-widget-tweaks" }, { name = "django-widget-tweaks" },
{ name = "gunicorn" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "slippers" }, { name = "slippers" },
{ name = "stripe" },
{ name = "uvicorn" },
{ name = "uvicorn-worker" },
{ name = "wagtail" }, { name = "wagtail" },
{ name = "wagtail-color-panel" }, { name = "wagtail-color-panel" },
{ name = "wagtailmedia" }, { name = "wagtailmedia" },
@@ -514,8 +555,12 @@ requires-dist = [
{ name = "django-oauth-toolkit", specifier = ">=3.2.0" }, { name = "django-oauth-toolkit", specifier = ">=3.2.0" },
{ name = "django-tailwind", specifier = ">=4.4.2" }, { name = "django-tailwind", specifier = ">=4.4.2" },
{ name = "django-widget-tweaks", specifier = ">=1.5.1" }, { name = "django-widget-tweaks", specifier = ">=1.5.1" },
{ name = "gunicorn", specifier = ">=25.3.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" }, { name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "slippers", specifier = ">=0.6.2" }, { name = "slippers", specifier = ">=0.6.2" },
{ name = "stripe", specifier = ">=15.1.0" },
{ name = "uvicorn", specifier = ">=0.42.0" },
{ name = "uvicorn-worker", specifier = ">=0.4.0" },
{ name = "wagtail", specifier = "==7.3rc1" }, { name = "wagtail", specifier = "==7.3rc1" },
{ name = "wagtail-color-panel", specifier = ">=1.7.1" }, { name = "wagtail-color-panel", specifier = ">=1.7.1" },
{ name = "wagtailmedia", specifier = ">=0.17.2" }, { name = "wagtailmedia", specifier = ">=0.17.2" },
@@ -619,36 +664,45 @@ wheels = [
] ]
[[package]] [[package]]
name = "pillow" name = "packaging"
version = "12.1.1" version = "26.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, ]
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, [[package]]
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, name = "pillow"
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, version = "12.2.0"
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, source = { registry = "https://pypi.org/simple" }
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
] ]
[[package]] [[package]]
@@ -687,20 +741,20 @@ wheels = [
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" version = "2.20.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
] ]
[[package]] [[package]]
name = "pyjwt" name = "pyjwt"
version = "2.11.0" version = "2.12.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -778,7 +832,7 @@ wheels = [
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.32.5" version = "2.33.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "certifi" }, { name = "certifi" },
@@ -786,22 +840,22 @@ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
] ]
[[package]] [[package]]
name = "rich" name = "rich"
version = "14.3.3" version = "15.0.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markdown-it-py" }, { name = "markdown-it-py" },
{ name = "pygments" }, { name = "pygments" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
] ]
[[package]] [[package]]
@@ -815,7 +869,7 @@ wheels = [
[[package]] [[package]]
name = "slippers" name = "slippers"
version = "0.6.2" version = "0.6.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
@@ -823,9 +877,9 @@ dependencies = [
{ name = "typeguard" }, { name = "typeguard" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a7/c6/6af4500f31e53e9f782d264cb715f1a8cc9ac4248fba9fe2fe172822ea79/slippers-0.6.2.tar.gz", hash = "sha256:4cb555b8822ba0d404e5405723f5d723994022c29046008ee917081031bc0cf1", size = 59499, upload-time = "2023-08-01T05:04:18.964Z" } sdist = { url = "https://files.pythonhosted.org/packages/c3/3c/979ab01ee6515fb3d6f10cfc10ef2e6219817828b972c54d49f6ddd9ddd2/slippers-0.6.3.tar.gz", hash = "sha256:8602f462da79b707d25ec050fb51b6cbe5fb225b3c7faf40d1f02876d970e9f0", size = 59793, upload-time = "2026-03-30T04:51:59.436Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/8c/1fe2803870861f9a7f0cf502e4d1361dbb3685997118f4f76fd83ec9272f/slippers-0.6.2-py3-none-any.whl", hash = "sha256:739e05f85354becbf0a65daab831eea62557d89e7512042209ab629af4378bca", size = 61359, upload-time = "2023-08-01T05:04:17.378Z" }, { url = "https://files.pythonhosted.org/packages/9c/b9/76c4b0535d26089d0d01f5c9cb3529b121babd1abf605e6c7e12acedb09b/slippers-0.6.3-py3-none-any.whl", hash = "sha256:cddd92cb998fb48472f863a811566f5a187b392f65ae4b0df81008b56dff214c", size = 62528, upload-time = "2026-03-30T04:51:58.227Z" },
] ]
[[package]] [[package]]
@@ -846,6 +900,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
] ]
[[package]]
name = "stripe"
version = "15.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/45/26/5d6f5f5beae6f1ff78213e2e6f4fbd431518dcd98733cdd39fb4ba0d01d3/stripe-15.1.0.tar.gz", hash = "sha256:24bd3b6bd0969a4841bd4d7681556a9e35e46c414a07c8590a225fbd5a878450", size = 1501673, upload-time = "2026-04-24T00:18:58.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/4e/fd9cb74ddf1e61fb6241e2f6799a81ef99bf6cf2e94f8812ee1cd5458e5d/stripe-15.1.0-py3-none-any.whl", hash = "sha256:bdfb556be08662a41833e6403607ebf12e0062cae4f9b93e2b89b6ba926d7c82", size = 2143199, upload-time = "2026-04-24T00:18:56.027Z" },
]
[[package]] [[package]]
name = "telepath" name = "telepath"
version = "0.3.1" version = "0.3.1"
@@ -884,11 +951,11 @@ wheels = [
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2025.3" version = "2026.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
] ]
[[package]] [[package]]
@@ -900,6 +967,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
] ]
[[package]]
name = "uvicorn"
version = "0.45.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/62b0d9a2cfc8b4de6771322dae30f2db76c66dae9ec32e94e176a44ad563/uvicorn-0.45.0.tar.gz", hash = "sha256:3fe650df136c5bd2b9b06efc5980636344a2fbb840e9ddd86437d53144fa335d", size = 87818, upload-time = "2026-04-21T10:43:46.815Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" },
]
[[package]]
name = "uvicorn-worker"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gunicorn" },
{ name = "uvicorn" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/25/09cd7a90c8bb7fb693be0d6704fccd5f9778d5513214b7a01cc4a94ff314/uvicorn_worker-0.4.0-py3-none-any.whl", hash = "sha256:e2ed952cef976f5e9e429d7269640bbcafbd36c80aa80f1003c8c77a6797abde", size = 5364, upload-time = "2025-09-20T10:46:59.776Z" },
]
[[package]] [[package]]
name = "wagtail" name = "wagtail"
version = "7.3rc1" version = "7.3rc1"
@@ -931,14 +1024,14 @@ wheels = [
[[package]] [[package]]
name = "wagtail-color-panel" name = "wagtail-color-panel"
version = "1.7.1" version = "1.8.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "wagtail" }, { name = "wagtail" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/58/bd/043c8cfc6eae14303b555782add80148db0596441e8770723cf17e664d4d/wagtail_color_panel-1.7.1.tar.gz", hash = "sha256:efc42058a49c39bea6a61523b230c71cf31e5aeaa269281c53146eeec02fc509", size = 10315, upload-time = "2025-11-23T06:07:53.765Z" } sdist = { url = "https://files.pythonhosted.org/packages/9e/44/bd3e5a9ee9bc9b72153e0a227aae5b7e181d2ce4e601ff9b01c1074fbbb5/wagtail_color_panel-1.8.1.tar.gz", hash = "sha256:aca26f8b7178ff596aa2eac8982167396bbbc222fadbae4b6b1f18291b33473a", size = 10776, upload-time = "2026-04-12T19:16:14.464Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/1d/d004a5b57c8b1db6edee953e3222436c050dff2667320ca345964551640a/wagtail_color_panel-1.7.1-py3-none-any.whl", hash = "sha256:77ee02a6dd08b21e8806bb3e8c7dc01ad679dbeef864ad915fec32f5e8bcd574", size = 9383, upload-time = "2025-11-23T06:07:52.733Z" }, { url = "https://files.pythonhosted.org/packages/6d/bb/86ee676a4613c0dade91685fb3402766de4c20874ad6f18d91807d9813cc/wagtail_color_panel-1.8.1-py3-none-any.whl", hash = "sha256:6e8aaeceac3618a9b69370b5097fa4d3c2db19b31bff95b7726343660793e55e", size = 9256, upload-time = "2026-04-12T19:16:13.68Z" },
] ]
[[package]] [[package]]