From c7f5edd23fa723e6601bf8a9751e0afd204e383f Mon Sep 17 00:00:00 2001 From: Anthony Do Date: Sat, 11 Jul 2026 07:19:49 +0000 Subject: [PATCH 01/23] feat: integrate Keycloak IAM with unified first-admin bootstrap and Django OIDC admin auth - add Keycloak to dev/prod deployment flow with realm skeleton import support - implement Django admin OIDC login integration using Keycloak - add custom auth backend to map claims and effective realm roles to Django staff/superuser - fetch effective roles through Keycloak Admin API to resolve admin authorization reliably - add scripts to bootstrap env, deploy with Keycloak, provision linked admin user, and sync live admin password - unify first-login credentials under FIRST_PLATFORM_ADMIN and sync related Keycloak/Django aliases - ensure the same first admin identity is linked across Keycloak and Django admin paths - update env examples and README documentation for setup, first-login credentials, and sync behavior --- .env.prod.example | 48 ++ README.md | 56 ++ client/.env.example | 5 +- docker-compose.prod.yml | 19 + docker-compose.yml | 15 + keycloak/agronomy-club-realm-skeleton.json | 256 +++++++ .../import/agronomy-club-realm-skeleton.json | 259 +++++++ scripts/bootstrap-keycloak-env.sh | 270 ++++++++ scripts/deploy-with-keycloak.sh | 27 + scripts/ensure-keycloak-test-admin.sh | 222 ++++++ scripts/sync-keycloak-first-admin-password.sh | 138 ++++ server/.env.example | 32 +- server/agronomy_club/auth_backends.py | 184 +++++ server/agronomy_club/auth_views.py | 11 + server/agronomy_club/models.py | 18 +- server/api/settings.py | 48 ++ server/api/urls.py | 11 + server/poetry.lock | 632 ++++++++++++++---- server/pyproject.toml | 1 + 19 files changed, 2097 insertions(+), 155 deletions(-) create mode 100644 keycloak/agronomy-club-realm-skeleton.json create mode 100644 keycloak/import/agronomy-club-realm-skeleton.json create mode 100644 scripts/bootstrap-keycloak-env.sh create mode 100644 scripts/deploy-with-keycloak.sh create mode 100644 scripts/ensure-keycloak-test-admin.sh create mode 100644 scripts/sync-keycloak-first-admin-password.sh create mode 100644 server/agronomy_club/auth_backends.py create mode 100644 server/agronomy_club/auth_views.py diff --git a/.env.prod.example b/.env.prod.example index 54645cb..a5a4260 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -19,3 +19,51 @@ DJANGO_SUPERUSER_EMAIL= CHANGE THIS TO A VALID EMAIL DJANGO_SUPERUSER_USERNAME= CHANGE THIS TO A VALID USERNAME FRONTEND_URL="https://xxxx.codersforcauses.org/" + +# KEYCLOAK +KEYCLOAK_HTTP_PORT=8080 +KEYCLOAK_MANAGEMENT_PORT=9000 +KEYCLOAK_IMPORT_REALM_ON_STARTUP=false + +KC_BOOTSTRAP_ADMIN_USERNAME=admin +KC_BOOTSTRAP_ADMIN_PASSWORD= CHANGE THIS TO A RANDOM STRING +KC_DB=postgres +KC_DB_URL=jdbc:postgresql://db:5432/postgres +KC_DB_USERNAME=postgres +KC_DB_PASSWORD=password +KC_HOSTNAME=auth.example.com +KC_HTTP_ENABLED=true +KC_PROXY_HEADERS=xforwarded +KC_HEALTH_ENABLED=true +KC_METRICS_ENABLED=true + +KEYCLOAK_BASE_URL=https://auth.example.com +KEYCLOAK_REALM=agronomy-club +KEYCLOAK_ISSUER=https://auth.example.com/realms/agronomy-club +KEYCLOAK_JWKS_URL=https://auth.example.com/realms/agronomy-club/protocol/openid-connect/certs +KEYCLOAK_AUTH_URL=https://auth.example.com/realms/agronomy-club/protocol/openid-connect/auth +KEYCLOAK_TOKEN_URL=https://auth.example.com/realms/agronomy-club/protocol/openid-connect/token +KEYCLOAK_USERINFO_URL=https://auth.example.com/realms/agronomy-club/protocol/openid-connect/userinfo + +DJANGO_OIDC_CLIENT_ID=django-admin +DJANGO_OIDC_CLIENT_SECRET= CHANGE THIS TO A RANDOM STRING +FIRST_PLATFORM_ADMIN_USERNAME=admin +FIRST_PLATFORM_ADMIN_EMAIL=admin@example.com +FIRST_PLATFORM_ADMIN_PASSWORD= CHANGE THIS TO A RANDOM STRING +FIRST_DJANGO_ADMIN_USERNAME=admin +FIRST_DJANGO_ADMIN_EMAIL=admin@example.com +FIRST_DJANGO_ADMIN_PASSWORD= CHANGE THIS TO A RANDOM STRING +FIRST_KEYCLOAK_ADMIN_USERNAME=admin +FIRST_KEYCLOAK_ADMIN_PASSWORD= CHANGE THIS TO A RANDOM STRING +KEYCLOAK_SYNC_EFFECTIVE_ROLES=true +KEYCLOAK_ADMIN_REALM=master +KEYCLOAK_ADMIN_CLIENT_ID=admin-cli +KEYCLOAK_ADMIN_CLIENT_SECRET= +KEYCLOAK_ADMIN_USERNAME=admin +KEYCLOAK_ADMIN_PASSWORD= CHANGE THIS TO A RANDOM STRING +KEYCLOAK_TEST_ADMIN_EMAIL=admin@example.com +KEYCLOAK_TEST_ADMIN_USERNAME=admin +KEYCLOAK_TEST_ADMIN_PASSWORD= CHANGE THIS TO A RANDOM STRING +NEXT_PUBLIC_KEYCLOAK_URL=https://auth.example.com +NEXT_PUBLIC_KEYCLOAK_REALM=agronomy-club +NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=agronomy-web diff --git a/README.md b/README.md index 92a3814..5ceba41 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,62 @@ If you run into migration conflicts that you can't be bothered to fix, run `nuke ## Other +### Keycloak Deployment + +This repository now includes a Keycloak deployment path and a realm skeleton. + +1. Bootstrap environment variables (creates missing values and secrets): +```bash +./scripts/bootstrap-keycloak-env.sh ./.env +``` + +2. Start dev docker services (db + keycloak): +```bash +docker compose -f docker-compose.yml up -d +``` + +3. One-command helper (bootstraps env, copies realm skeleton into import folder, starts compose): +```bash +./scripts/deploy-with-keycloak.sh +``` + +First login credentials are unified as one account for both Keycloak and Django admin (set these in root `.env`): + +- `FIRST_PLATFORM_ADMIN_USERNAME` +- `FIRST_PLATFORM_ADMIN_EMAIL` +- `FIRST_PLATFORM_ADMIN_PASSWORD` + +This same account is used for: +- Keycloak admin panel (`http://localhost:8080/admin`) +- Django admin panel with Keycloak auth (`http://localhost:8000/admin`) + +These are propagated by `./scripts/bootstrap-keycloak-env.sh` into: +- `KC_BOOTSTRAP_ADMIN_*` and `KEYCLOAK_ADMIN_*` for Keycloak admin API/auth +- `KEYCLOAK_TEST_ADMIN_*` (and `DJANGO_SUPERUSER_*`) for Django admin test/bootstrap login +- `FIRST_KEYCLOAK_ADMIN_*` and `FIRST_DJANGO_ADMIN_*` as synchronized aliases + +Legacy variables `FIRST_KEYCLOAK_ADMIN_*` and `FIRST_DJANGO_ADMIN_*` are still supported as fallbacks for backwards compatibility. + +To force an already-running Keycloak instance to use your configured first admin password: +```bash +./scripts/sync-keycloak-first-admin-password.sh ./.env +``` + +`./scripts/deploy-with-keycloak.sh` runs this sync automatically after Keycloak starts. + +4. Production helper: +```bash +./scripts/deploy-with-keycloak.sh --prod +``` + +Realm skeleton source: +- `keycloak/agronomy-club-realm-skeleton.json` + +Realm import path used by docker compose: +- `keycloak/import/agronomy-club-realm-skeleton.json` + +Note: chapter access scope groups are templated with `__CHAPTER_ID__` and should be generated for real chapter IDs. + ### Update Dependencies You can run `npm install` and `poetry install` in the respective `client` and `server` folders to install the newest dependencies. diff --git a/client/.env.example b/client/.env.example index 63e7f21..7a79d7c 100644 --- a/client/.env.example +++ b/client/.env.example @@ -1,4 +1,7 @@ REACT_EDITOR=code APP_ENV=DEVELOPMENT -NEXT_PUBLIC_BACKEND_URL="http://localhost:8000/api" \ No newline at end of file +NEXT_PUBLIC_BACKEND_URL="http://localhost:8000/api" +NEXT_PUBLIC_KEYCLOAK_URL="http://localhost:8080" +NEXT_PUBLIC_KEYCLOAK_REALM="agronomy-club" +NEXT_PUBLIC_KEYCLOAK_CLIENT_ID="agronomy-web" \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b1eae07..1767c21 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -11,6 +11,24 @@ services: timeout: 3s retries: 5 + keycloak: + image: quay.io/keycloak/keycloak:26.2 + restart: unless-stopped + env_file: ./.env.prod + command: + - start + - --hostname=${KC_HOSTNAME} + - --proxy-headers=${KC_PROXY_HEADERS:-xforwarded} + - --http-enabled=${KC_HTTP_ENABLED:-true} + ports: + - "${KEYCLOAK_HTTP_PORT:-8080}:8080" + - "${KEYCLOAK_MANAGEMENT_PORT:-9000}:9000" + volumes: + - ./docker/keycloak/themes:/opt/keycloak/themes:ro + depends_on: + db: + condition: service_healthy + server: image: ghcr.io/codersforcauses/django-nextjs-template-prod-server:latest restart: unless-stopped @@ -25,6 +43,7 @@ services: - DJANGO_SETTINGS_MODULE=api.settings depends_on: - db + - keycloak client: image: ghcr.io/codersforcauses/django-nextjs-template-prod-client:latest diff --git a/docker-compose.yml b/docker-compose.yml index 30caf05..5e56c96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,3 +12,18 @@ services: env_file: ./server/.env ports: - 5432:5432 + + keycloak: + image: quay.io/keycloak/keycloak:26.2 + restart: unless-stopped + env_file: ./.env + command: ["start-dev", "--import-realm"] + ports: + - "${KEYCLOAK_HTTP_PORT:-8080}:8080" + - "${KEYCLOAK_MANAGEMENT_PORT:-9000}:9000" + volumes: + - ./keycloak/import:/opt/keycloak/data/import:ro + - ./docker/keycloak/themes:/opt/keycloak/themes:ro + depends_on: + db: + condition: service_healthy diff --git a/keycloak/agronomy-club-realm-skeleton.json b/keycloak/agronomy-club-realm-skeleton.json new file mode 100644 index 0000000..62c8bf6 --- /dev/null +++ b/keycloak/agronomy-club-realm-skeleton.json @@ -0,0 +1,256 @@ +{ + "realm": "agronomy-club", + "enabled": true, + "displayName": "Agronomy Club", + "registrationAllowed": true, + "registrationEmailAsUsername": true, + "rememberMe": true, + "verifyEmail": true, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "roles": { + "realm": [ + { + "name": "platform_admin", + "description": "Full platform administration role with global cross-chapter access" + }, + { + "name": "user", + "description": "Default non-privileged app user" + }, + { + "name": "alumni", + "description": "Alumni visibility role" + }, + { + "name": "committee_pres", + "description": "Chapter committee president" + }, + { + "name": "committee_vpres", + "description": "Chapter committee vice president" + }, + { + "name": "committee_sec", + "description": "Chapter committee secretary" + }, + { + "name": "committee_treas", + "description": "Chapter committee treasurer" + }, + { + "name": "committee_mark", + "description": "Chapter committee marketing" + }, + { + "name": "committee_ocm", + "description": "Chapter committee ordinary member" + } + ] + }, + "defaultRoles": [ + "user" + ], + "groups": [ + { + "name": "chapters", + "path": "/chapters", + "subGroups": [ + { + "name": "__CHAPTER_ID__", + "path": "/chapters/__CHAPTER_ID__", + "subGroups": [ + { + "name": "owner", + "path": "/chapters/__CHAPTER_ID__/owner" + }, + { + "name": "admin", + "path": "/chapters/__CHAPTER_ID__/admin" + }, + { + "name": "member", + "path": "/chapters/__CHAPTER_ID__/member" + } + ] + } + ] + } + ], + "clients": [ + { + "clientId": "django-admin", + "name": "Django Admin Client", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "bearerOnly": false, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:8081/*", + "http://localhost:8000/*" + ], + "webOrigins": [ + "http://localhost:8081", + "http://localhost:8000" + ], + "secret": "CHANGE_ME_DJANGO_ADMIN_CLIENT_SECRET", + "defaultClientScopes": [ + "profile", + "email", + "roles" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "protocolMappers": [ + { + "name": "realm-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + }, + { + "name": "group-membership", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "clientId": "agronomy-api", + "name": "Agronomy API Resource Server", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "bearerOnly": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "attributes": { + "chapter.scope.enforcement": "required", + "chapter.scope.claim": "groups", + "chapter.scope.key": "chapter_id", + "platform.admin.bypass.role": "platform_admin" + }, + "protocolMappers": [ + { + "name": "api-client-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.agronomy-api.roles", + "jsonType.label": "String", + "client.id": "agronomy-api" + } + } + ] + }, + { + "clientId": "agronomy-web", + "name": "Agronomy Web App", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "bearerOnly": false, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:3000/*" + ], + "webOrigins": [ + "http://localhost:3000" + ], + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "defaultClientScopes": [ + "profile", + "email", + "roles" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "protocolMappers": [ + { + "name": "realm-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + }, + { + "name": "group-membership", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "name": "api-client-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.agronomy-api.roles", + "jsonType.label": "String", + "client.id": "agronomy-api" + } + } + ] + } + ] +} diff --git a/keycloak/import/agronomy-club-realm-skeleton.json b/keycloak/import/agronomy-club-realm-skeleton.json new file mode 100644 index 0000000..209ee2f --- /dev/null +++ b/keycloak/import/agronomy-club-realm-skeleton.json @@ -0,0 +1,259 @@ +{ + "realm": "agronomy-club", + "enabled": true, + "displayName": "Agronomy Club", + "registrationAllowed": true, + "registrationEmailAsUsername": true, + "rememberMe": true, + "verifyEmail": true, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "roles": { + "realm": [ + { + "name": "platform_admin", + "description": "Full platform administration role with global cross-chapter access" + }, + { + "name": "user", + "description": "Default non-privileged app user" + }, + { + "name": "alumni", + "description": "Alumni visibility role" + }, + { + "name": "committee_pres", + "description": "Chapter committee president" + }, + { + "name": "committee_vpres", + "description": "Chapter committee vice president" + }, + { + "name": "committee_sec", + "description": "Chapter committee secretary" + }, + { + "name": "committee_treas", + "description": "Chapter committee treasurer" + }, + { + "name": "committee_mark", + "description": "Chapter committee marketing" + }, + { + "name": "committee_ocm", + "description": "Chapter committee ordinary member" + } + ] + }, + "defaultRoles": [ + "user" + ], + "groups": [ + { + "name": "chapters", + "path": "/chapters", + "subGroups": [ + { + "name": "__CHAPTER_ID__", + "path": "/chapters/__CHAPTER_ID__", + "subGroups": [ + { + "name": "owner", + "path": "/chapters/__CHAPTER_ID__/owner" + }, + { + "name": "admin", + "path": "/chapters/__CHAPTER_ID__/admin" + }, + { + "name": "member", + "path": "/chapters/__CHAPTER_ID__/member" + } + ] + } + ] + } + ], + "clients": [ + { + "clientId": "django-admin", + "name": "Django Admin Client", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "bearerOnly": false, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:8081/*", + "http://localhost:8000/*" + ], + "webOrigins": [ + "http://localhost:8081", + "http://localhost:8000" + ], + "secret": "CHANGE_ME_DJANGO_ADMIN_CLIENT_SECRET", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "defaultClientScopes": [ + "profile", + "email", + "roles" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "protocolMappers": [ + { + "name": "realm-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + }, + { + "name": "group-membership", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "clientId": "agronomy-api", + "name": "Agronomy API Resource Server", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "bearerOnly": true, + "standardFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "attributes": { + "chapter.scope.enforcement": "required", + "chapter.scope.claim": "groups", + "chapter.scope.key": "chapter_id", + "platform.admin.bypass.role": "platform_admin" + }, + "protocolMappers": [ + { + "name": "api-client-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.agronomy-api.roles", + "jsonType.label": "String", + "client.id": "agronomy-api" + } + } + ] + }, + { + "clientId": "agronomy-web", + "name": "Agronomy Web App", + "enabled": true, + "protocol": "openid-connect", + "publicClient": true, + "bearerOnly": false, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "redirectUris": [ + "http://localhost:3000/*" + ], + "webOrigins": [ + "http://localhost:3000" + ], + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "defaultClientScopes": [ + "profile", + "email", + "roles" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ], + "protocolMappers": [ + { + "name": "realm-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + }, + { + "name": "group-membership", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper", + "consentRequired": false, + "config": { + "full.path": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "name": "api-client-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.agronomy-api.roles", + "jsonType.label": "String", + "client.id": "agronomy-api" + } + } + ] + } + ] +} diff --git a/scripts/bootstrap-keycloak-env.sh b/scripts/bootstrap-keycloak-env.sh new file mode 100644 index 0000000..6dcd872 --- /dev/null +++ b/scripts/bootstrap-keycloak-env.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${1:-${ROOT_DIR}/.env}" + +if [[ ! -f "${ENV_FILE}" ]]; then + touch "${ENV_FILE}" +fi + +read_env_value() { + local key="$1" + local line + + if [[ -n "${!key:-}" ]]; then + printf '%s' "${!key}" + return 0 + fi + + line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)" + if [[ -n "${line}" ]]; then + printf '%s' "${line#*=}" + fi +} + +read_fallback_env_value() { + local key="$1" + local fallback_file="$2" + local line + + if [[ ! -f "${fallback_file}" ]]; then + return 0 + fi + + line="$(grep -E "^${key}=" "${fallback_file}" | tail -n 1 || true)" + if [[ -n "${line}" ]]; then + printf '%s' "${line#*=}" + fi +} + +generate_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 24 + else + tr -dc 'A-Za-z0-9' >"${ENV_FILE}" + echo "Added ${key} to ${ENV_FILE}" +} + +upsert_env_value() { + local key="$1" + local value="$2" + + if grep -q -E "^${key}=" "${ENV_FILE}"; then + sed -i "s|^${key}=.*$|${key}=${value}|" "${ENV_FILE}" + else + printf '%s=%s\n' "${key}" "${value}" >>"${ENV_FILE}" + echo "Added ${key} to ${ENV_FILE}" + fi +} + +postgres_name="$(read_env_value POSTGRES_NAME)" +postgres_user="$(read_env_value POSTGRES_USER)" +postgres_password="$(read_env_value POSTGRES_PASSWORD)" + +first_keycloak_admin_username="$(read_env_value FIRST_KEYCLOAK_ADMIN_USERNAME)" +first_keycloak_admin_password="$(read_env_value FIRST_KEYCLOAK_ADMIN_PASSWORD)" +first_django_admin_username="$(read_env_value FIRST_DJANGO_ADMIN_USERNAME)" +first_django_admin_email="$(read_env_value FIRST_DJANGO_ADMIN_EMAIL)" +first_django_admin_password="$(read_env_value FIRST_DJANGO_ADMIN_PASSWORD)" +first_platform_admin_username="$(read_env_value FIRST_PLATFORM_ADMIN_USERNAME)" +first_platform_admin_email="$(read_env_value FIRST_PLATFORM_ADMIN_EMAIL)" +first_platform_admin_password="$(read_env_value FIRST_PLATFORM_ADMIN_PASSWORD)" + +if [[ -z "${first_platform_admin_username}" ]]; then + first_platform_admin_username="${first_keycloak_admin_username}" +fi +if [[ -z "${first_platform_admin_username}" ]]; then + first_platform_admin_username="${first_django_admin_username}" +fi +if [[ -z "${first_platform_admin_username}" ]]; then + first_platform_admin_username="admin" +fi + +if [[ -z "${first_platform_admin_email}" ]]; then + first_platform_admin_email="${first_django_admin_email}" +fi +if [[ -z "${first_platform_admin_email}" ]]; then + first_platform_admin_email="admin@example.com" +fi + +if [[ -z "${first_platform_admin_password}" ]]; then + first_platform_admin_password="${first_keycloak_admin_password}" +fi +if [[ -z "${first_platform_admin_password}" ]]; then + first_platform_admin_password="${first_django_admin_password}" +fi +if [[ -z "${first_platform_admin_password}" ]]; then + first_platform_admin_password="ChangeMePlatformAdmin#2026" +fi + +if [[ -z "${first_keycloak_admin_username}" ]]; then + first_keycloak_admin_username="${first_platform_admin_username}" +fi +if [[ -z "${first_keycloak_admin_password}" ]]; then + first_keycloak_admin_password="${first_platform_admin_password}" +fi +if [[ -z "${first_django_admin_username}" ]]; then + first_django_admin_username="${first_platform_admin_username}" +fi +if [[ -z "${first_django_admin_email}" ]]; then + first_django_admin_email="${first_platform_admin_email}" +fi +if [[ -z "${first_django_admin_password}" ]]; then + first_django_admin_password="${first_platform_admin_password}" +fi + +fallback_env_file="${ROOT_DIR}/server/.env" +if [[ "${ENV_FILE}" == *".env.prod" ]]; then + fallback_env_file="${ROOT_DIR}/.env.prod" +fi + +if [[ -z "${postgres_name}" ]]; then + postgres_name="$(read_fallback_env_value POSTGRES_NAME "${fallback_env_file}")" +fi +if [[ -z "${postgres_name}" ]]; then + postgres_name="postgres" +fi + +if [[ -z "${postgres_user}" ]]; then + postgres_user="$(read_fallback_env_value POSTGRES_USER "${fallback_env_file}")" +fi +if [[ -z "${postgres_user}" ]]; then + postgres_user="postgres" +fi + +if [[ -z "${postgres_password}" ]]; then + postgres_password="$(read_fallback_env_value POSTGRES_PASSWORD "${fallback_env_file}")" +fi +if [[ -z "${postgres_password}" ]]; then + postgres_password="password" +fi + +keycloak_http_port="$(read_env_value KEYCLOAK_HTTP_PORT)" +if [[ -z "${keycloak_http_port}" ]]; then + keycloak_http_port="8080" +fi + +realm_name="$(read_env_value KEYCLOAK_REALM)" +if [[ -z "${realm_name}" ]]; then + realm_name="agronomy-club" +fi + +keycloak_base_url="$(read_env_value KEYCLOAK_BASE_URL)" +if [[ -z "${keycloak_base_url}" ]]; then + keycloak_base_url="http://localhost:${keycloak_http_port}" +fi + +keycloak_db_url="$(read_env_value KC_DB_URL)" +if [[ -z "${keycloak_db_url}" ]]; then + keycloak_db_url="jdbc:postgresql://db:5432/${postgres_name}" +fi + +append_if_missing APP_ENV DEVELOPMENT +append_if_missing APP_NAME DjangoAPI +append_if_missing API_SECRET_KEY "$(generate_secret)" +append_if_missing API_ALLOWED_HOSTS ".localhost 127.0.0.1 [::1]" +append_if_missing POSTGRES_HOST db +append_if_missing POSTGRES_NAME "${postgres_name}" +append_if_missing POSTGRES_USER "${postgres_user}" +append_if_missing POSTGRES_PASSWORD "${postgres_password}" +append_if_missing POSTGRES_PORT 5432 +append_if_missing DJANGO_SUPERUSER_USERNAME "${first_django_admin_username}" +append_if_missing DJANGO_SUPERUSER_EMAIL "${first_django_admin_email}" +append_if_missing DJANGO_SUPERUSER_PASSWORD "${first_django_admin_password}" +append_if_missing FIRST_PLATFORM_ADMIN_USERNAME "${first_platform_admin_username}" +append_if_missing FIRST_PLATFORM_ADMIN_EMAIL "${first_platform_admin_email}" +append_if_missing FIRST_PLATFORM_ADMIN_PASSWORD "${first_platform_admin_password}" +append_if_missing FIRST_DJANGO_ADMIN_USERNAME "${first_django_admin_username}" +append_if_missing FIRST_DJANGO_ADMIN_EMAIL "${first_django_admin_email}" +append_if_missing FIRST_DJANGO_ADMIN_PASSWORD "${first_django_admin_password}" +append_if_missing FRONTEND_URL "http://localhost:3000" +append_if_missing NEXT_PUBLIC_BACKEND_URL "http://localhost:8081/api" +append_if_missing KEYCLOAK_HTTP_PORT "${keycloak_http_port}" +append_if_missing KEYCLOAK_MANAGEMENT_PORT 9000 +append_if_missing KEYCLOAK_IMPORT_REALM_ON_STARTUP true + +append_if_missing FIRST_KEYCLOAK_ADMIN_USERNAME "${first_keycloak_admin_username}" +append_if_missing FIRST_KEYCLOAK_ADMIN_PASSWORD "${first_keycloak_admin_password}" +append_if_missing KC_BOOTSTRAP_ADMIN_USERNAME "${first_keycloak_admin_username}" +append_if_missing KC_BOOTSTRAP_ADMIN_PASSWORD "${first_keycloak_admin_password}" +append_if_missing KC_DB postgres +append_if_missing KC_DB_URL "${keycloak_db_url}" +append_if_missing KC_DB_USERNAME "${postgres_user}" +append_if_missing KC_DB_PASSWORD "${postgres_password}" +append_if_missing KC_HOSTNAME localhost +append_if_missing KC_HTTP_ENABLED true +append_if_missing KC_PROXY_HEADERS xforwarded +append_if_missing KC_HEALTH_ENABLED true +append_if_missing KC_METRICS_ENABLED true + +bootstrap_admin_username="$(read_env_value KC_BOOTSTRAP_ADMIN_USERNAME)" +bootstrap_admin_password="$(read_env_value KC_BOOTSTRAP_ADMIN_PASSWORD)" +if [[ -z "${bootstrap_admin_username}" ]]; then + bootstrap_admin_username="admin" +fi +if [[ -z "${bootstrap_admin_password}" ]]; then + bootstrap_admin_password="$(generate_secret)" +fi + +append_if_missing KEYCLOAK_BASE_URL "${keycloak_base_url}" +append_if_missing KEYCLOAK_REALM "${realm_name}" +append_if_missing KEYCLOAK_ISSUER "${keycloak_base_url}/realms/${realm_name}" +append_if_missing KEYCLOAK_JWKS_URL "${keycloak_base_url}/realms/${realm_name}/protocol/openid-connect/certs" +append_if_missing KEYCLOAK_AUTH_URL "${keycloak_base_url}/realms/${realm_name}/protocol/openid-connect/auth" +append_if_missing KEYCLOAK_TOKEN_URL "${keycloak_base_url}/realms/${realm_name}/protocol/openid-connect/token" +append_if_missing KEYCLOAK_USERINFO_URL "${keycloak_base_url}/realms/${realm_name}/protocol/openid-connect/userinfo" + +append_if_missing DJANGO_OIDC_CLIENT_ID django-admin +append_if_missing DJANGO_OIDC_CLIENT_SECRET "$(generate_secret)" +append_if_missing KEYCLOAK_SYNC_EFFECTIVE_ROLES true +append_if_missing KEYCLOAK_ADMIN_REALM master +append_if_missing KEYCLOAK_ADMIN_CLIENT_ID admin-cli +append_if_missing KEYCLOAK_ADMIN_CLIENT_SECRET "" +append_if_missing KEYCLOAK_ADMIN_USERNAME "${bootstrap_admin_username}" +append_if_missing KEYCLOAK_ADMIN_PASSWORD "${bootstrap_admin_password}" +append_if_missing KEYCLOAK_TEST_ADMIN_EMAIL "${first_django_admin_email}" +append_if_missing KEYCLOAK_TEST_ADMIN_USERNAME "${first_django_admin_username}" +append_if_missing KEYCLOAK_TEST_ADMIN_PASSWORD "${first_django_admin_password}" +append_if_missing NEXT_PUBLIC_KEYCLOAK_URL "${keycloak_base_url}" +append_if_missing NEXT_PUBLIC_KEYCLOAK_REALM "${realm_name}" +append_if_missing NEXT_PUBLIC_KEYCLOAK_CLIENT_ID agronomy-web + +append_if_missing AUTH_SOURCE keycloak +append_if_missing ENABLE_LOCAL_ADMIN_FALLBACK true + +# Use FIRST_PLATFORM_ADMIN_* as the single source of truth for first-login identity. +upsert_env_value FIRST_DJANGO_ADMIN_USERNAME "${first_platform_admin_username}" +upsert_env_value FIRST_DJANGO_ADMIN_EMAIL "${first_platform_admin_email}" +upsert_env_value FIRST_DJANGO_ADMIN_PASSWORD "${first_platform_admin_password}" +upsert_env_value FIRST_KEYCLOAK_ADMIN_USERNAME "${first_platform_admin_username}" +upsert_env_value FIRST_KEYCLOAK_ADMIN_PASSWORD "${first_platform_admin_password}" + +upsert_env_value DJANGO_SUPERUSER_USERNAME "${first_platform_admin_username}" +upsert_env_value DJANGO_SUPERUSER_EMAIL "${first_platform_admin_email}" +upsert_env_value DJANGO_SUPERUSER_PASSWORD "${first_platform_admin_password}" + +upsert_env_value KC_BOOTSTRAP_ADMIN_USERNAME "${first_platform_admin_username}" +upsert_env_value KC_BOOTSTRAP_ADMIN_PASSWORD "${first_platform_admin_password}" +upsert_env_value KEYCLOAK_ADMIN_USERNAME "${first_platform_admin_username}" +upsert_env_value KEYCLOAK_ADMIN_PASSWORD "${first_platform_admin_password}" + +upsert_env_value KEYCLOAK_TEST_ADMIN_USERNAME "${first_platform_admin_username}" +upsert_env_value KEYCLOAK_TEST_ADMIN_EMAIL "${first_platform_admin_email}" +upsert_env_value KEYCLOAK_TEST_ADMIN_PASSWORD "${first_platform_admin_password}" + +echo "Keycloak environment bootstrap complete (${ENV_FILE})." diff --git a/scripts/deploy-with-keycloak.sh b/scripts/deploy-with-keycloak.sh new file mode 100644 index 0000000..a1485ed --- /dev/null +++ b/scripts/deploy-with-keycloak.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="${ROOT_DIR}/docker-compose.yml" +ENV_FILE="${ROOT_DIR}/.env" + +if [[ "${1:-}" == "--prod" ]]; then + COMPOSE_FILE="${ROOT_DIR}/docker-compose.prod.yml" + ENV_FILE="${ROOT_DIR}/.env.prod" +fi + +"${ROOT_DIR}/scripts/bootstrap-keycloak-env.sh" "${ENV_FILE}" + +mkdir -p "${ROOT_DIR}/keycloak/import" +cp "${ROOT_DIR}/keycloak/agronomy-club-realm-skeleton.json" \ + "${ROOT_DIR}/keycloak/import/agronomy-club-realm-skeleton.json" + +docker compose -f "${COMPOSE_FILE}" up -d + +"${ROOT_DIR}/scripts/sync-keycloak-first-admin-password.sh" "${ENV_FILE}" + +if [[ "${COMPOSE_FILE}" == *"docker-compose.yml" ]]; then + "${ROOT_DIR}/scripts/ensure-keycloak-test-admin.sh" "${ENV_FILE}" +fi + +echo "Deployment started with ${COMPOSE_FILE}" diff --git a/scripts/ensure-keycloak-test-admin.sh b/scripts/ensure-keycloak-test-admin.sh new file mode 100644 index 0000000..b735d03 --- /dev/null +++ b/scripts/ensure-keycloak-test-admin.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${1:-${ROOT_DIR}/.env}" + +read_env_value() { + local key="$1" + local line + + line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)" + if [[ -n "${line}" ]]; then + printf '%s' "${line#*=}" + fi +} + +upsert_env_value() { + local key="$1" + local value="$2" + + if grep -q -E "^${key}=" "${ENV_FILE}"; then + sed -i "s|^${key}=.*$|${key}=${value}|" "${ENV_FILE}" + else + printf '%s=%s\n' "${key}" "${value}" >>"${ENV_FILE}" + fi +} + +keycloak_http_port="$(read_env_value KEYCLOAK_HTTP_PORT)" +KEYCLOAK_BASE_URL="$(read_env_value KEYCLOAK_BASE_URL)" +KEYCLOAK_REALM="$(read_env_value KEYCLOAK_REALM)" +KEYCLOAK_ADMIN_REALM="$(read_env_value KEYCLOAK_ADMIN_REALM)" +KEYCLOAK_ADMIN_CLIENT_ID="$(read_env_value KEYCLOAK_ADMIN_CLIENT_ID)" +KEYCLOAK_ADMIN_CLIENT_SECRET="$(read_env_value KEYCLOAK_ADMIN_CLIENT_SECRET)" +KEYCLOAK_ADMIN_USERNAME="$(read_env_value KEYCLOAK_ADMIN_USERNAME)" +KEYCLOAK_ADMIN_PASSWORD="$(read_env_value KEYCLOAK_ADMIN_PASSWORD)" + +if [[ -z "${keycloak_http_port}" ]]; then + keycloak_http_port="8080" +fi +if [[ -z "${KEYCLOAK_BASE_URL}" ]]; then + KEYCLOAK_BASE_URL="http://localhost:${keycloak_http_port}" +fi +if [[ -z "${KEYCLOAK_REALM}" ]]; then + KEYCLOAK_REALM="agronomy-club" +fi +if [[ -z "${KEYCLOAK_ADMIN_REALM}" ]]; then + KEYCLOAK_ADMIN_REALM="master" +fi +if [[ -z "${KEYCLOAK_ADMIN_CLIENT_ID}" ]]; then + KEYCLOAK_ADMIN_CLIENT_ID="admin-cli" +fi +if [[ -z "${KEYCLOAK_ADMIN_USERNAME}" ]]; then + KEYCLOAK_ADMIN_USERNAME="$(read_env_value FIRST_PLATFORM_ADMIN_USERNAME)" +fi +if [[ -z "${KEYCLOAK_ADMIN_USERNAME}" ]]; then + KEYCLOAK_ADMIN_USERNAME="$(read_env_value FIRST_KEYCLOAK_ADMIN_USERNAME)" +fi +if [[ -z "${KEYCLOAK_ADMIN_USERNAME}" ]]; then + KEYCLOAK_ADMIN_USERNAME="$(read_env_value KC_BOOTSTRAP_ADMIN_USERNAME)" +fi +if [[ -z "${KEYCLOAK_ADMIN_PASSWORD}" ]]; then + KEYCLOAK_ADMIN_PASSWORD="$(read_env_value FIRST_PLATFORM_ADMIN_PASSWORD)" +fi +if [[ -z "${KEYCLOAK_ADMIN_PASSWORD}" ]]; then + KEYCLOAK_ADMIN_PASSWORD="$(read_env_value FIRST_KEYCLOAK_ADMIN_PASSWORD)" +fi +if [[ -z "${KEYCLOAK_ADMIN_PASSWORD}" ]]; then + KEYCLOAK_ADMIN_PASSWORD="$(read_env_value KC_BOOTSTRAP_ADMIN_PASSWORD)" +fi + +TEST_USER_EMAIL="$(read_env_value KEYCLOAK_TEST_ADMIN_EMAIL)" +TEST_USER_USERNAME="$(read_env_value KEYCLOAK_TEST_ADMIN_USERNAME)" +TEST_USER_PASSWORD="$(read_env_value KEYCLOAK_TEST_ADMIN_PASSWORD)" +DJANGO_OIDC_CLIENT_ID="$(read_env_value DJANGO_OIDC_CLIENT_ID)" + +if [[ -z "${TEST_USER_EMAIL}" ]]; then + TEST_USER_EMAIL="$(read_env_value FIRST_PLATFORM_ADMIN_EMAIL)" +fi +if [[ -z "${TEST_USER_EMAIL}" ]]; then + TEST_USER_EMAIL="$(read_env_value FIRST_DJANGO_ADMIN_EMAIL)" +fi +if [[ -z "${TEST_USER_EMAIL}" ]]; then + TEST_USER_EMAIL="django-admin-test@example.com" +fi +if [[ -z "${TEST_USER_USERNAME}" ]]; then + TEST_USER_USERNAME="$(read_env_value FIRST_PLATFORM_ADMIN_USERNAME)" +fi +if [[ -z "${TEST_USER_USERNAME}" ]]; then + TEST_USER_USERNAME="$(read_env_value FIRST_DJANGO_ADMIN_USERNAME)" +fi +if [[ -z "${TEST_USER_USERNAME}" ]]; then + TEST_USER_USERNAME="${TEST_USER_EMAIL}" +fi +if [[ -z "${TEST_USER_PASSWORD}" ]]; then + TEST_USER_PASSWORD="$(read_env_value FIRST_PLATFORM_ADMIN_PASSWORD)" +fi +if [[ -z "${TEST_USER_PASSWORD}" ]]; then + TEST_USER_PASSWORD="$(read_env_value FIRST_DJANGO_ADMIN_PASSWORD)" +fi +if [[ -z "${TEST_USER_PASSWORD}" ]]; then + TEST_USER_PASSWORD="DjangoOIDC#2026" +fi +if [[ -z "${DJANGO_OIDC_CLIENT_ID}" ]]; then + DJANGO_OIDC_CLIENT_ID="django-admin" +fi + +if [[ -z "${KEYCLOAK_ADMIN_USERNAME}" || -z "${KEYCLOAK_ADMIN_PASSWORD}" ]]; then + echo "Missing KEYCLOAK_ADMIN_USERNAME/KEYCLOAK_ADMIN_PASSWORD (or KC_BOOTSTRAP_ADMIN_*)" >&2 + exit 1 +fi + +for _ in {1..30}; do + if curl -fsS "${KEYCLOAK_BASE_URL}/health/ready" >/dev/null 2>&1; then + break + fi + sleep 2 +done + +ACCESS_TOKEN="$({ + curl -fsS -X POST "${KEYCLOAK_BASE_URL}/realms/${KEYCLOAK_ADMIN_REALM}/protocol/openid-connect/token" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode "client_id=${KEYCLOAK_ADMIN_CLIENT_ID}" \ + --data-urlencode "username=${KEYCLOAK_ADMIN_USERNAME}" \ + --data-urlencode "password=${KEYCLOAK_ADMIN_PASSWORD}" \ + --data-urlencode 'grant_type=password' \ + ${KEYCLOAK_ADMIN_CLIENT_SECRET:+--data-urlencode "client_secret=${KEYCLOAK_ADMIN_CLIENT_SECRET}"} +} | jq -r '.access_token')" + +if [[ -z "${ACCESS_TOKEN}" || "${ACCESS_TOKEN}" == "null" ]]; then + echo "Failed to obtain Keycloak admin access token" >&2 + exit 1 +fi + +DJANGO_OIDC_CLIENT_INTERNAL_ID="$(curl -fsS -G \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + --data-urlencode "clientId=${DJANGO_OIDC_CLIENT_ID}" \ + "${KEYCLOAK_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/clients" | jq -r '.[0].id // empty')" + +if [[ -n "${DJANGO_OIDC_CLIENT_INTERNAL_ID}" ]]; then + DJANGO_OIDC_CLIENT_SECRET="$(curl -fsS \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "${KEYCLOAK_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${DJANGO_OIDC_CLIENT_INTERNAL_ID}/client-secret" | jq -r '.value // empty')" + if [[ -n "${DJANGO_OIDC_CLIENT_SECRET}" ]]; then + upsert_env_value DJANGO_OIDC_CLIENT_SECRET "${DJANGO_OIDC_CLIENT_SECRET}" + fi +fi + +user_lookup="$(curl -fsS -G \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + --data-urlencode "email=${TEST_USER_EMAIL}" \ + --data-urlencode "exact=true" \ + "${KEYCLOAK_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/users")" + +USER_ID="$(printf '%s' "${user_lookup}" | jq -r '.[0].id // empty')" + +if [[ -z "${USER_ID}" ]]; then + create_payload="$(jq -n \ + --arg username "${TEST_USER_USERNAME}" \ + --arg email "${TEST_USER_EMAIL}" \ + --arg password "${TEST_USER_PASSWORD}" \ + '{ + username: $username, + email: $email, + enabled: true, + emailVerified: true, + credentials: [{type: "password", value: $password, temporary: false}] + }')" + + curl -fsS -X POST \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "${create_payload}" \ + "${KEYCLOAK_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/users" >/dev/null + + user_lookup="$(curl -fsS -G \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + --data-urlencode "email=${TEST_USER_EMAIL}" \ + --data-urlencode "exact=true" \ + "${KEYCLOAK_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/users")" + USER_ID="$(printf '%s' "${user_lookup}" | jq -r '.[0].id // empty')" +fi + +if [[ -z "${USER_ID}" ]]; then + echo "Unable to resolve test user id in Keycloak" >&2 + exit 1 +fi + +update_payload="$(jq -n \ + --arg username "${TEST_USER_USERNAME}" \ + --arg email "${TEST_USER_EMAIL}" \ + '{username: $username, email: $email, enabled: true, emailVerified: true}')" + +curl -fsS -X PUT \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "${update_payload}" \ + "${KEYCLOAK_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/users/${USER_ID}" >/dev/null + +reset_payload="$(jq -n --arg password "${TEST_USER_PASSWORD}" '{type: "password", value: $password, temporary: false}')" +curl -fsS -X PUT \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "${reset_payload}" \ + "${KEYCLOAK_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/users/${USER_ID}/reset-password" >/dev/null + +platform_admin_role="$(curl -fsS \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "${KEYCLOAK_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/roles/platform_admin")" + +current_roles="$(curl -fsS \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + "${KEYCLOAK_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/users/${USER_ID}/role-mappings/realm/composite")" + +if ! printf '%s' "${current_roles}" | jq -e '.[] | select(.name == "platform_admin")' >/dev/null; then + curl -fsS -X POST \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H 'Content-Type: application/json' \ + -d "[$platform_admin_role]" \ + "${KEYCLOAK_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/users/${USER_ID}/role-mappings/realm" >/dev/null +fi + +echo "Keycloak test admin user ready: ${TEST_USER_EMAIL} (role=platform_admin)" diff --git a/scripts/sync-keycloak-first-admin-password.sh b/scripts/sync-keycloak-first-admin-password.sh new file mode 100644 index 0000000..9aa3b47 --- /dev/null +++ b/scripts/sync-keycloak-first-admin-password.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${1:-${ROOT_DIR}/.env}" + +read_env_value() { + local key="$1" + local line + + line="$(grep -E "^${key}=" "${ENV_FILE}" | tail -n 1 || true)" + if [[ -n "${line}" ]]; then + printf '%s' "${line#*=}" + fi +} + +upsert_env_value() { + local key="$1" + local value="$2" + + if grep -q -E "^${key}=" "${ENV_FILE}"; then + sed -i "s|^${key}=.*$|${key}=${value}|" "${ENV_FILE}" + else + printf '%s=%s\n' "${key}" "${value}" >>"${ENV_FILE}" + fi +} + +keycloak_http_port="$(read_env_value KEYCLOAK_HTTP_PORT)" +keycloak_base_url="$(read_env_value KEYCLOAK_BASE_URL)" +keycloak_admin_realm="$(read_env_value KEYCLOAK_ADMIN_REALM)" +keycloak_admin_client_id="$(read_env_value KEYCLOAK_ADMIN_CLIENT_ID)" +keycloak_admin_client_secret="$(read_env_value KEYCLOAK_ADMIN_CLIENT_SECRET)" +keycloak_admin_username="$(read_env_value KEYCLOAK_ADMIN_USERNAME)" +keycloak_admin_password_current="$(read_env_value KEYCLOAK_ADMIN_PASSWORD)" +keycloak_admin_password_target="$(read_env_value FIRST_PLATFORM_ADMIN_PASSWORD)" +first_keycloak_admin_username="$(read_env_value FIRST_KEYCLOAK_ADMIN_USERNAME)" +first_platform_admin_username="$(read_env_value FIRST_PLATFORM_ADMIN_USERNAME)" + +if [[ -z "${keycloak_http_port}" ]]; then + keycloak_http_port="8080" +fi +if [[ -z "${keycloak_base_url}" ]]; then + keycloak_base_url="http://localhost:${keycloak_http_port}" +fi +if [[ -z "${keycloak_admin_realm}" ]]; then + keycloak_admin_realm="master" +fi +if [[ -z "${keycloak_admin_client_id}" ]]; then + keycloak_admin_client_id="admin-cli" +fi +if [[ -z "${keycloak_admin_username}" ]]; then + keycloak_admin_username="${first_platform_admin_username}" +fi +if [[ -z "${keycloak_admin_username}" ]]; then + keycloak_admin_username="${first_keycloak_admin_username}" +fi + +if [[ -z "${keycloak_admin_username}" ]]; then + echo "Missing KEYCLOAK_ADMIN_USERNAME or FIRST_KEYCLOAK_ADMIN_USERNAME in ${ENV_FILE}" >&2 + exit 1 +fi + +if [[ -z "${keycloak_admin_password_current}" ]]; then + echo "Missing KEYCLOAK_ADMIN_PASSWORD in ${ENV_FILE}" >&2 + exit 1 +fi + +if [[ -z "${keycloak_admin_password_target}" ]]; then + keycloak_admin_password_target="$(read_env_value FIRST_KEYCLOAK_ADMIN_PASSWORD)" +fi +if [[ -z "${keycloak_admin_password_target}" ]]; then + echo "Missing FIRST_KEYCLOAK_ADMIN_PASSWORD in ${ENV_FILE}" >&2 + exit 1 +fi + +if [[ "${keycloak_admin_password_current}" == "${keycloak_admin_password_target}" ]]; then + echo "Keycloak admin password already matches FIRST_KEYCLOAK_ADMIN_PASSWORD" + exit 0 +fi + +for _ in {1..30}; do + if curl -fsS "${keycloak_base_url}/health/ready" >/dev/null 2>&1; then + break + fi + sleep 2 +done + +get_admin_token() { + local password="$1" + + curl -fsS -X POST "${keycloak_base_url}/realms/${keycloak_admin_realm}/protocol/openid-connect/token" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode "client_id=${keycloak_admin_client_id}" \ + --data-urlencode "username=${keycloak_admin_username}" \ + --data-urlencode "password=${password}" \ + --data-urlencode 'grant_type=password' \ + ${keycloak_admin_client_secret:+--data-urlencode "client_secret=${keycloak_admin_client_secret}"} \ + | jq -r '.access_token // empty' +} + +access_token="$(get_admin_token "${keycloak_admin_password_current}" || true)" +if [[ -z "${access_token}" ]]; then + echo "Unable to authenticate to Keycloak with KEYCLOAK_ADMIN_PASSWORD; cannot rotate admin password" >&2 + exit 1 +fi + +admin_user_id="$(curl -fsS -G \ + -H "Authorization: Bearer ${access_token}" \ + --data-urlencode "username=${keycloak_admin_username}" \ + --data-urlencode 'exact=true' \ + "${keycloak_base_url}/admin/realms/${keycloak_admin_realm}/users" | jq -r '.[0].id // empty')" + +if [[ -z "${admin_user_id}" ]]; then + echo "Unable to find Keycloak admin user '${keycloak_admin_username}' in realm '${keycloak_admin_realm}'" >&2 + exit 1 +fi + +reset_payload="$(jq -n --arg password "${keycloak_admin_password_target}" '{type: "password", value: $password, temporary: false}')" + +curl -fsS -X PUT \ + -H "Authorization: Bearer ${access_token}" \ + -H 'Content-Type: application/json' \ + -d "${reset_payload}" \ + "${keycloak_base_url}/admin/realms/${keycloak_admin_realm}/users/${admin_user_id}/reset-password" >/dev/null + +# Verify new password works before writing back to env. +new_token="$(get_admin_token "${keycloak_admin_password_target}" || true)" +if [[ -z "${new_token}" ]]; then + echo "Password reset call succeeded but verification with FIRST_KEYCLOAK_ADMIN_PASSWORD failed" >&2 + exit 1 +fi + +upsert_env_value KEYCLOAK_ADMIN_PASSWORD "${keycloak_admin_password_target}" +upsert_env_value KC_BOOTSTRAP_ADMIN_PASSWORD "${keycloak_admin_password_target}" +upsert_env_value FIRST_PLATFORM_ADMIN_PASSWORD "${keycloak_admin_password_target}" +upsert_env_value FIRST_KEYCLOAK_ADMIN_PASSWORD "${keycloak_admin_password_target}" + +echo "Synced live Keycloak admin password to FIRST_KEYCLOAK_ADMIN_PASSWORD" diff --git a/server/.env.example b/server/.env.example index 88efad2..39122c0 100644 --- a/server/.env.example +++ b/server/.env.example @@ -14,4 +14,34 @@ DJANGO_SUPERUSER_PASSWORD=Password123 DJANGO_SUPERUSER_EMAIL=admin@test.com DJANGO_SUPERUSER_USERNAME=admin -FRONTEND_URL="http://localhost:3000" \ No newline at end of file +FRONTEND_URL="http://localhost:3000" + +AUTH_SOURCE=local +ENABLE_LOCAL_ADMIN_FALLBACK=true + +KEYCLOAK_BASE_URL="http://localhost:8080" +KEYCLOAK_REALM="agronomy-club" +KEYCLOAK_ISSUER="http://localhost:8080/realms/agronomy-club" +KEYCLOAK_JWKS_URL="http://localhost:8080/realms/agronomy-club/protocol/openid-connect/certs" +KEYCLOAK_AUTH_URL="http://localhost:8080/realms/agronomy-club/protocol/openid-connect/auth" +KEYCLOAK_TOKEN_URL="http://localhost:8080/realms/agronomy-club/protocol/openid-connect/token" +KEYCLOAK_USERINFO_URL="http://localhost:8080/realms/agronomy-club/protocol/openid-connect/userinfo" +DJANGO_OIDC_CLIENT_ID="django-admin" +DJANGO_OIDC_CLIENT_SECRET="CHANGE_ME" +FIRST_PLATFORM_ADMIN_USERNAME="admin" +FIRST_PLATFORM_ADMIN_EMAIL="admin@example.com" +FIRST_PLATFORM_ADMIN_PASSWORD="ChangeMePlatformAdmin#2026" +FIRST_DJANGO_ADMIN_USERNAME="admin" +FIRST_DJANGO_ADMIN_EMAIL="admin@example.com" +FIRST_DJANGO_ADMIN_PASSWORD="ChangeMePlatformAdmin#2026" +FIRST_KEYCLOAK_ADMIN_USERNAME="admin" +FIRST_KEYCLOAK_ADMIN_PASSWORD="ChangeMePlatformAdmin#2026" +KEYCLOAK_SYNC_EFFECTIVE_ROLES=true +KEYCLOAK_ADMIN_REALM="master" +KEYCLOAK_ADMIN_CLIENT_ID="admin-cli" +KEYCLOAK_ADMIN_CLIENT_SECRET="" +KEYCLOAK_ADMIN_USERNAME="admin" +KEYCLOAK_ADMIN_PASSWORD="ChangeMePlatformAdmin#2026" +KEYCLOAK_TEST_ADMIN_EMAIL="admin@example.com" +KEYCLOAK_TEST_ADMIN_USERNAME="admin" +KEYCLOAK_TEST_ADMIN_PASSWORD="ChangeMePlatformAdmin#2026" \ No newline at end of file diff --git a/server/agronomy_club/auth_backends.py b/server/agronomy_club/auth_backends.py new file mode 100644 index 0000000..f0f47b7 --- /dev/null +++ b/server/agronomy_club/auth_backends.py @@ -0,0 +1,184 @@ +import logging +import json +import time +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from django.conf import settings +from django.contrib.auth import get_user_model +from mozilla_django_oidc.auth import OIDCAuthenticationBackend + + +logger = logging.getLogger(__name__) + + +class KeycloakOIDCAuthenticationBackend(OIDCAuthenticationBackend): + """Maps Keycloak OIDC claims to Django users and admin privileges.""" + + _admin_access_token = None + _admin_access_token_expiry = 0 + + def _extract_roles(self, claims): + roles = set() + + nested_roles = (claims.get("realm_access") or {}).get("roles") or [] + roles.update(nested_roles) + + dotted_roles = claims.get("realm_access.roles") or [] + if isinstance(dotted_roles, str): + roles.add(dotted_roles) + else: + roles.update(dotted_roles) + + top_level_roles = claims.get("roles") or [] + if isinstance(top_level_roles, str): + roles.add(top_level_roles) + else: + roles.update(top_level_roles) + + return roles + + def _http_json(self, url, method="GET", headers=None, form_data=None): + request_headers = headers.copy() if headers else {} + request_body = None + + if form_data is not None: + request_body = urlencode(form_data).encode("utf-8") + request_headers["Content-Type"] = "application/x-www-form-urlencoded" + + req = Request(url=url, data=request_body, headers=request_headers, method=method) + with urlopen(req, timeout=5) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) if raw else {} + + def _keycloak_base(self): + return (settings.KEYCLOAK_BASE_URL or "").rstrip("/") + + def _get_admin_access_token(self): + now = int(time.time()) + if self._admin_access_token and now < self._admin_access_token_expiry: + return self._admin_access_token + + if not settings.KEYCLOAK_ADMIN_USERNAME or not settings.KEYCLOAK_ADMIN_PASSWORD: + return None + + token_url = ( + f"{self._keycloak_base()}/realms/{settings.KEYCLOAK_ADMIN_REALM}" + "/protocol/openid-connect/token" + ) + form_data = { + "grant_type": "password", + "client_id": settings.KEYCLOAK_ADMIN_CLIENT_ID, + "username": settings.KEYCLOAK_ADMIN_USERNAME, + "password": settings.KEYCLOAK_ADMIN_PASSWORD, + } + if settings.KEYCLOAK_ADMIN_CLIENT_SECRET: + form_data["client_secret"] = settings.KEYCLOAK_ADMIN_CLIENT_SECRET + + try: + token_response = self._http_json(token_url, method="POST", form_data=form_data) + except (HTTPError, URLError, TimeoutError, ValueError) as exc: + logger.warning("Keycloak admin token request failed: %s", exc) + return None + + access_token = token_response.get("access_token") + expires_in = int(token_response.get("expires_in") or 60) + if not access_token: + return None + + self._admin_access_token = access_token + self._admin_access_token_expiry = now + max(expires_in - 10, 10) + return self._admin_access_token + + def _fetch_effective_realm_roles(self, claims): + if not getattr(settings, "KEYCLOAK_SYNC_EFFECTIVE_ROLES", False): + return set() + + subject = claims.get("sub") + if not subject: + return set() + + token = self._get_admin_access_token() + if not token: + return set() + + url = ( + f"{self._keycloak_base()}/admin/realms/{settings.KEYCLOAK_REALM}" + f"/users/{subject}/role-mappings/realm/composite" + ) + headers = {"Authorization": f"Bearer {token}"} + + try: + role_payload = self._http_json(url, headers=headers) + except (HTTPError, URLError, TimeoutError, ValueError) as exc: + logger.warning("Keycloak effective role fetch failed for sub=%s: %s", subject, exc) + return set() + + roles = set() + if isinstance(role_payload, list): + for role in role_payload: + role_name = (role or {}).get("name") + if role_name: + roles.add(role_name) + return roles + + def _resolve_roles(self, claims): + roles = self._extract_roles(claims) + roles.update(self._fetch_effective_realm_roles(claims)) + return roles + + def _apply_role_flags(self, user, claims): + roles = self._resolve_roles(claims) + is_platform_admin = "platform_admin" in roles + + user.is_active = True + user.is_staff = is_platform_admin + user.is_superuser = is_platform_admin + user.save(update_fields=["is_active", "is_staff", "is_superuser"]) + + def filter_users_by_claims(self, claims): + UserModel = get_user_model() + email = claims.get("email") + if email: + users = UserModel.objects.filter(email__iexact=email) + for user in users: + self._apply_role_flags(user, claims) + return users + + username = claims.get("preferred_username") + if username: + users = UserModel.objects.filter(username__iexact=username) + for user in users: + self._apply_role_flags(user, claims) + return users + + return UserModel.objects.none() + + def create_user(self, claims): + UserModel = get_user_model() + email = claims.get("email") or "" + base_username = claims.get("preferred_username") or (email.split("@")[0] if email else "kc-user") + username = base_username + suffix = 1 + + while UserModel.objects.filter(username=username).exists(): + username = f"{base_username}-{suffix}" + suffix += 1 + + user = UserModel.objects.create_user( + username=username, + email=email, + first_name=claims.get("given_name", ""), + last_name=claims.get("family_name", ""), + ) + return self.update_user(user, claims) + + def update_user(self, user, claims): + user.email = claims.get("email", user.email) + user.first_name = claims.get("given_name", user.first_name) + user.last_name = claims.get("family_name", user.last_name) + user.save(update_fields=["email", "first_name", "last_name"]) + self._apply_role_flags(user, claims) + + return user diff --git a/server/agronomy_club/auth_views.py b/server/agronomy_club/auth_views.py new file mode 100644 index 0000000..15d4eed --- /dev/null +++ b/server/agronomy_club/auth_views.py @@ -0,0 +1,11 @@ +from urllib.parse import urlencode + +from django.http import HttpRequest +from django.shortcuts import redirect +from django.urls import reverse + + +def admin_login_via_oidc(request: HttpRequest): + next_url = request.GET.get("next") or "/admin/" + query = urlencode({"next": next_url}) + return redirect(f"{reverse('oidc_authentication_init')}?{query}") diff --git a/server/agronomy_club/models.py b/server/agronomy_club/models.py index d89dcf5..cfc38e3 100644 --- a/server/agronomy_club/models.py +++ b/server/agronomy_club/models.py @@ -4,7 +4,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator # noqa from django import forms -# Model for chapters information such as logo, location, description and email. Chapter members should be stored in a seperate model. +# Model for Chapter information such as logo, location, description and email. Chapter members should be stored in a seperate model. # This model uses django-colorfield to store the colour of the chapter as well as provide a color picker widget in admin panel. # Documentation can be found here: https://github.com/fabiocaccamo/django-colorfield#readme @@ -21,7 +21,7 @@ def random_color(): new_color = generate_random_hex() # If color does not exist yet in database then return it, otherwise generate a new one - if not Chapters.objects.filter(colour=new_color).exists(): + if not Chapter.objects.filter(colour=new_color).exists(): return new_color @@ -34,7 +34,7 @@ def max_value_curr_year(value): return MaxValueValidator(current_year() + 12)(value) -class Chapters(models.Model): +class Chapter(models.Model): id = models.AutoField(primary_key=True, auto_created=True, unique=True) name = models.CharField(max_length=255) abbrev = models.CharField(max_length=255) @@ -56,7 +56,7 @@ class Event(models.Model): location = models.CharField(max_length=255) date = models.DateTimeField() thumbnail = models.ImageField(upload_to="event_thumbnails/", null=True, blank=True) - chapter = models.ForeignKey(Chapters, on_delete=models.CASCADE, related_name="events") + chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="events") def __str__(self): return f"{self.title} - {self.chapter}" @@ -65,7 +65,7 @@ def __str__(self): class Quiz(models.Model): name = models.CharField(max_length=100) public = models.BooleanField() - chapter = models.ForeignKey(Chapters, on_delete=models.CASCADE) + chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE) upload_date = models.DateTimeField(auto_now_add=True) quiz_data = models.JSONField() @@ -84,7 +84,7 @@ def __str__(self): class Resource(models.Model): id = models.AutoField(primary_key=True, auto_created=True, unique=True) - chapter = models.ForeignKey(Chapters, on_delete=models.CASCADE, related_name="resources") + chapter = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name="resources") name = models.CharField(max_length=100) link = models.URLField(max_length=255) upload_date = models.DateTimeField(auto_now_add=True) @@ -94,7 +94,7 @@ def __str__(self): return f"{self.name} - {self.chapter}" -class Users(models.Model): +class User(models.Model): id = models.AutoField(primary_key=True, auto_created=True, unique=True) full_name = models.CharField(max_length=100) grad_yr = models.PositiveIntegerField(validators=[MinValueValidator(1900), max_value_curr_year]) @@ -108,9 +108,9 @@ def __str__(self): class ChapterMemberships(models.Model): id = models.AutoField(primary_key=True, auto_created=True, unique=True) - user_id = models.ForeignKey(Users, on_delete=models.CASCADE, related_name='user_memberships') + user_id = models.ForeignKey(User, on_delete=models.CASCADE, related_name='user_memberships') chapter_role = models.CharField(max_length=100, choices=[('member', 'Member'), ('admin', 'Admin'), ('owner', 'Owner')], default='member') - chapter_id = models.ForeignKey(Chapters, on_delete=models.CASCADE, related_name='chapter_memberships') + chapter_id = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name='chapter_memberships') position = models.CharField(max_length=100, choices=[('pres', 'President'), ('vpres', 'Vice President'), ('sec', 'Secretary'), ('treas', 'Treasurer'), ('mark', 'Marketing Officer'), ('ocm', 'Ordinary Committee Member')], default='pres', blank=True) diff --git a/server/api/settings.py b/server/api/settings.py index 45a2920..812fc00 100644 --- a/server/api/settings.py +++ b/server/api/settings.py @@ -17,10 +17,18 @@ load_dotenv() +def env_bool(name, default=False): + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() in ("1", "true", "yes", "on") + + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent FRONTEND_URL = os.environ.get("FRONTEND_URL") +AUTH_SOURCE = os.environ.get("AUTH_SOURCE", "local").lower() # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ @@ -55,6 +63,9 @@ "agronomy_club" ] +if AUTH_SOURCE == "keycloak": + INSTALLED_APPS.append("mozilla_django_oidc") + MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -164,3 +175,40 @@ # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +if AUTH_SOURCE == "keycloak": + AUTHENTICATION_BACKENDS = [ + "agronomy_club.auth_backends.KeycloakOIDCAuthenticationBackend", + "django.contrib.auth.backends.ModelBackend", + ] + + KEYCLOAK_BASE_URL = os.environ.get("KEYCLOAK_BASE_URL", "") + KEYCLOAK_REALM = os.environ.get("KEYCLOAK_REALM", "agronomy-club") + + OIDC_RP_CLIENT_ID = os.environ.get("DJANGO_OIDC_CLIENT_ID", "django-admin") + OIDC_RP_CLIENT_SECRET = os.environ.get("DJANGO_OIDC_CLIENT_SECRET", "") + OIDC_OP_AUTHORIZATION_ENDPOINT = os.environ.get("KEYCLOAK_AUTH_URL", "") + OIDC_OP_TOKEN_ENDPOINT = os.environ.get("KEYCLOAK_TOKEN_URL", "") + OIDC_OP_USER_ENDPOINT = os.environ.get("KEYCLOAK_USERINFO_URL", "") + OIDC_OP_JWKS_ENDPOINT = os.environ.get("KEYCLOAK_JWKS_URL", "") + OIDC_OP_LOGOUT_ENDPOINT = KEYCLOAK_BASE_URL.rstrip("/") + "/realms/" + KEYCLOAK_REALM + "/protocol/openid-connect/logout" + + OIDC_RP_SIGN_ALGO = "RS256" + OIDC_USE_NONCE = True + OIDC_CREATE_USER = True + OIDC_UPDATE_USER = True + OIDC_RP_SCOPES = "openid email profile" + + OIDC_STORE_ACCESS_TOKEN = True + OIDC_STORE_ID_TOKEN = True + OIDC_VERIFY_JWT = env_bool("OIDC_VERIFY_JWT", True) + + KEYCLOAK_SYNC_EFFECTIVE_ROLES = env_bool("KEYCLOAK_SYNC_EFFECTIVE_ROLES", True) + KEYCLOAK_ADMIN_REALM = os.environ.get("KEYCLOAK_ADMIN_REALM", "master") + KEYCLOAK_ADMIN_CLIENT_ID = os.environ.get("KEYCLOAK_ADMIN_CLIENT_ID", "admin-cli") + KEYCLOAK_ADMIN_CLIENT_SECRET = os.environ.get("KEYCLOAK_ADMIN_CLIENT_SECRET", "") + KEYCLOAK_ADMIN_USERNAME = os.environ.get("KEYCLOAK_ADMIN_USERNAME") or os.environ.get("KC_BOOTSTRAP_ADMIN_USERNAME") + KEYCLOAK_ADMIN_PASSWORD = os.environ.get("KEYCLOAK_ADMIN_PASSWORD") or os.environ.get("KC_BOOTSTRAP_ADMIN_PASSWORD") + + LOGIN_REDIRECT_URL = "/admin/" + LOGOUT_REDIRECT_URL = "/admin/" diff --git a/server/api/urls.py b/server/api/urls.py index c3cfc2a..16198d8 100644 --- a/server/api/urls.py +++ b/server/api/urls.py @@ -15,11 +15,22 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +import os + from django.contrib import admin from django.urls import path, include +from agronomy_club.auth_views import admin_login_via_oidc + + +AUTH_SOURCE = os.environ.get("AUTH_SOURCE", "local").lower() + urlpatterns = [ + path("admin/login/", admin_login_via_oidc, name="admin-login-via-oidc"), path("admin/", admin.site.urls), path("api/healthcheck/", include(("api.healthcheck.urls"))), path("api/", include(("agronomy_club.urls"))), ] + +if AUTH_SOURCE == "keycloak": + urlpatterns.insert(0, path("oidc/", include("mozilla_django_oidc.urls"))) diff --git a/server/poetry.lock b/server/poetry.lock index ff2e8f2..d3dfaf4 100644 --- a/server/poetry.lock +++ b/server/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "asgiref" @@ -6,7 +6,6 @@ version = "3.11.1" description = "ASGI specs, helper code, and adapters" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"}, {file = "asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce"}, @@ -21,7 +20,6 @@ version = "2.15.8" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.7.2" -groups = ["dev"] files = [ {file = "astroid-2.15.8-py3-none-any.whl", hash = "sha256:1aa149fc5c6589e3d0ece885b4491acd80af4f087baafa3fb5203b113e68cd3c"}, {file = "astroid-2.15.8.tar.gz", hash = "sha256:6c107453dffee9055899705de3c9ead36e74119cee151e5a9aaf7f0b0e020a6a"}, @@ -31,29 +29,312 @@ files = [ lazy-object-proxy = ">=1.4.0" wrapt = {version = ">=1.14,<2", markers = "python_version >= \"3.11\""} +[[package]] +name = "certifi" +version = "2026.6.17" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +files = [ + {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, + {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, +] + +[[package]] +name = "cffi" +version = "2.1.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.10" +files = [ + {file = "cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0"}, + {file = "cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd"}, + {file = "cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f"}, + {file = "cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e"}, + {file = "cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479"}, + {file = "cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458"}, + {file = "cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b"}, + {file = "cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a"}, + {file = "cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384"}, + {file = "cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a"}, + {file = "cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2"}, + {file = "cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512"}, + {file = "cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326"}, + {file = "cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd"}, + {file = "cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb"}, + {file = "cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94"}, + {file = "cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76"}, + {file = "cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5"}, + {file = "cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629"}, + {file = "cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6"}, + {file = "cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853"}, + {file = "cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9"}, + {file = "cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b"}, + {file = "cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5"}, + {file = "cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210"}, + {file = "cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +files = [ + {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe"}, + {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"}, + {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"}, +] + [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -groups = ["dev"] -markers = "sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "49.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +files = [ + {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"}, + {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"}, + {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"}, + {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"}, + {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"}, + {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"}, + {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + [[package]] name = "django" -version = "5.2.15" +version = "5.2.16" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ - {file = "django-5.2.15-py3-none-any.whl", hash = "sha256:0eb4a9bb1853a35b0286dbc6d916bd352c8c2687195a7f2d6f80cefd840e4970"}, - {file = "django-5.2.15.tar.gz", hash = "sha256:5154a9bf84ac01dde011e367f355c07dbb329532e06810dcf3ef2af269e236e7"}, + {file = "django-5.2.16-py3-none-any.whl", hash = "sha256:04f354bf9d807a86ad1a8392fe3808d362358a8eafc322848e0e43e59b24371d"}, + {file = "django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200"}, ] [package.dependencies] @@ -71,7 +352,6 @@ version = "0.14.0" description = "color field for django models with a nice color-picker in the admin." optional = false python-versions = "*" -groups = ["main"] files = [ {file = "django_colorfield-0.14.0-py3-none-any.whl", hash = "sha256:f169c4e7ad8f336e51d4ea81f866346e7d4f336f3766e54e144cd16ea7d84a0e"}, {file = "django_colorfield-0.14.0.tar.gz", hash = "sha256:478dbd3975a88f2ea2a9afc325faaca05c54ebf04ec985ce130f6dea39dfb899"}, @@ -86,7 +366,6 @@ version = "4.9.0" description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." optional = false python-versions = ">=3.9" -groups = ["main"] files = [ {file = "django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449"}, {file = "django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8"}, @@ -102,7 +381,6 @@ version = "3.2.3" description = "Extensions for Django" optional = false python-versions = ">=3.6" -groups = ["main"] files = [ {file = "django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a"}, {file = "django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401"}, @@ -117,7 +395,6 @@ version = "3.17.1" description = "Web APIs for Django, made easy." optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457"}, {file = "djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5"}, @@ -132,7 +409,6 @@ version = "6.1.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.8.1" -groups = ["dev"] files = [ {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, @@ -149,7 +425,6 @@ version = "1.4" description = "Plugin to catch bad style specific to Django Projects." optional = false python-versions = ">=3.7.2,<4.0.0" -groups = ["dev"] files = [ {file = "flake8_django-1.4.tar.gz", hash = "sha256:4debba883084191568e3187416d1d6bdd4abd826da988f197a3c36572e9f30de"}, ] @@ -164,7 +439,6 @@ version = "1.5.5" description = "Let your Python tests travel through time" optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"}, {file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"}, @@ -179,7 +453,6 @@ version = "23.0.0" description = "WSGI HTTP Server for UNIX" optional = false python-versions = ">=3.7" -groups = ["main"] files = [ {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, @@ -195,25 +468,54 @@ setproctitle = ["setproctitle"] testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] tornado = ["tornado (>=0.2)"] +[[package]] +name = "idna" +version = "3.18" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.9" +files = [ + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, +] + +[package.extras] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "iniconfig" version = "2.3.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.10" -groups = ["dev"] files = [ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "josepy" +version = "2.2.0" +description = "JOSE protocol implementation in Python" +optional = false +python-versions = ">=3.9.2" +files = [ + {file = "josepy-2.2.0-py3-none-any.whl", hash = "sha256:63e9dd116d4078778c25ca88f880cc5d95f1cab0099bebe3a34c2e299f65d10b"}, + {file = "josepy-2.2.0.tar.gz", hash = "sha256:74c033151337c854f83efe5305a291686cef723b4b970c43cfe7270cf4a677a9"}, +] + +[package.dependencies] +cryptography = ">=1.5" + +[package.extras] +docs = ["sphinx (>=4.3.0)", "sphinx-rtd-theme (>=1.0)"] + [[package]] name = "lazy-object-proxy" version = "1.12.0" description = "A fast and thorough lazy object proxy." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "lazy_object_proxy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61d5e3310a4aa5792c2b599a7a78ccf8687292c8eb09cf187cca8f09cf6a7519"}, {file = "lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ca33565f698ac1aece152a10f432415d1a2aa9a42dfe23e5ba2bc255ab91f6"}, @@ -267,19 +569,34 @@ version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" -groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mozilla-django-oidc" +version = "4.0.1" +description = "A lightweight authentication and access management library for integration with OpenID Connect enabled authentication services." +optional = false +python-versions = "*" +files = [ + {file = "mozilla-django-oidc-4.0.1.tar.gz", hash = "sha256:4ff8c64069e3e05c539cecf9345e73225a99641a25e13b7a5f933ec897b58918"}, + {file = "mozilla_django_oidc-4.0.1-py2.py3-none-any.whl", hash = "sha256:04ef58759be69f22cdc402d082480aaebf193466cad385dc9e4f8df2a0b187ca"}, +] + +[package.dependencies] +cryptography = "*" +Django = ">=3.2" +josepy = "*" +requests = "*" + [[package]] name = "packaging" version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "dev"] files = [ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, @@ -287,103 +604,98 @@ files = [ [[package]] name = "pillow" -version = "12.2.0" +version = "12.3.0" description = "Python Imaging Library (fork)" optional = false python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"}, - {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"}, - {file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"}, - {file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"}, - {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"}, - {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"}, - {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"}, - {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"}, - {file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"}, - {file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"}, - {file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"}, - {file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"}, - {file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"}, - {file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"}, - {file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"}, - {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"}, - {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"}, - {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"}, - {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"}, - {file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"}, - {file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"}, - {file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"}, - {file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"}, - {file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"}, - {file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"}, - {file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"}, - {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"}, - {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"}, - {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"}, - {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"}, - {file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"}, - {file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"}, - {file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"}, - {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"}, - {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"}, - {file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"}, - {file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"}, - {file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"}, - {file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"}, - {file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"}, - {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"}, - {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"}, - {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"}, - {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"}, - {file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"}, - {file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"}, - {file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"}, - {file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"}, - {file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"}, - {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"}, - {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"}, - {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"}, - {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"}, - {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"}, - {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"}, - {file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"}, - {file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"}, - {file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"}, - {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"}, - {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"}, - {file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"}, - {file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"}, - {file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"}, - {file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"}, - {file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"}, - {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"}, - {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"}, - {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"}, - {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"}, - {file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"}, - {file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"}, - {file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"}, - {file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"}, - {file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"}, - {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"}, - {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"}, - {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"}, - {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"}, - {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"}, - {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"}, - {file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"}, - {file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"}, - {file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"}, - {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"}, - {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"}, +files = [ + {file = "pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a"}, + {file = "pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7"}, + {file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f"}, + {file = "pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec"}, + {file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468"}, + {file = "pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed"}, + {file = "pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1"}, + {file = "pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb"}, + {file = "pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f"}, + {file = "pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756"}, + {file = "pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6"}, + {file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd"}, + {file = "pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd"}, + {file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c"}, + {file = "pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5"}, + {file = "pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b"}, + {file = "pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a"}, + {file = "pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26"}, + {file = "pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965"}, + {file = "pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7"}, + {file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9"}, + {file = "pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91"}, + {file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c"}, + {file = "pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df"}, + {file = "pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f"}, + {file = "pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09"}, + {file = "pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510"}, + {file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89"}, + {file = "pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace"}, + {file = "pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec"}, + {file = "pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66"}, + {file = "pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35"}, + {file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65"}, + {file = "pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3"}, + {file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a"}, + {file = "pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e"}, + {file = "pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f"}, + {file = "pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8"}, + {file = "pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b"}, + {file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330"}, + {file = "pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217"}, + {file = "pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930"}, + {file = "pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8"}, + {file = "pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0"}, + {file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321"}, + {file = "pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b"}, + {file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198"}, + {file = "pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130"}, + {file = "pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a"}, + {file = "pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d"}, + {file = "pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838"}, + {file = "pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e"}, + {file = "pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17"}, + {file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385"}, + {file = "pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c"}, + {file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d"}, + {file = "pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931"}, + {file = "pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7"}, + {file = "pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c"}, + {file = "pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45"}, + {file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139"}, + {file = "pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402"}, + {file = "pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c"}, + {file = "pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f"}, + {file = "pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701"}, + {file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace"}, + {file = "pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4"}, + {file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39"}, + {file = "pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71"}, + {file = "pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827"}, + {file = "pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5"}, + {file = "pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658"}, + {file = "pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf"}, + {file = "pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64"}, + {file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e"}, + {file = "pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777"}, + {file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1"}, + {file = "pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9"}, + {file = "pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8"}, + {file = "pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418"}, + {file = "pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3"}, + {file = "pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a"}, + {file = "pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce"}, ] [package.extras] @@ -391,7 +703,7 @@ docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybut fpx = ["olefile"] mic = ["olefile"] test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"] -tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +tests = ["coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "setuptools", "trove-classifiers (>=2024.10.12)"] xmp = ["defusedxml"] [[package]] @@ -400,7 +712,6 @@ version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -416,7 +727,6 @@ version = "3.3.4" description = "PostgreSQL database adapter for Python" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a"}, {file = "psycopg-3.3.4.tar.gz", hash = "sha256:e21207764952cff81b6b8bdacad9a3939f2793367fdac2987b3aac36a651b5bc"}, @@ -429,12 +739,12 @@ typing-extensions = {version = ">=4.6", markers = "python_version < \"3.13\""} tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] -binary = ["psycopg-binary (==3.3.4) ; implementation_name != \"pypy\""] -c = ["psycopg-c (==3.3.4) ; implementation_name != \"pypy\""] +binary = ["psycopg-binary (==3.3.4)"] +c = ["psycopg-c (==3.3.4)"] dev = ["ast-comments (>=1.1.2)", "black (>=26.1.0)", "codespell (>=2.2)", "cython-lint (>=0.16)", "dnspython (>=2.1)", "flake8 (>=4.0)", "isort-psycopg", "isort[colors] (>=6.0)", "mypy (>=1.19.0)", "pre-commit (>=4.0.1)", "types-setuptools (>=57.4)", "types-shapely (>=2.0)", "wheel (>=0.37)"] docs = ["Sphinx (>=9.1)", "furo (==2025.12.19)", "sphinx-autobuild (>=2025.8.25)", "sphinx-autodoc-typehints (>=3.10.2)"] pool = ["psycopg-pool"] -test = ["anyio (>=4.0)", "mypy (>=1.19.0) ; implementation_name != \"pypy\"", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] +test = ["anyio (>=4.0)", "mypy (>=1.19.0)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] [[package]] name = "psycopg-binary" @@ -442,8 +752,6 @@ version = "3.3.4" description = "PostgreSQL database adapter for Python -- C optimisation distribution" optional = false python-versions = ">=3.10" -groups = ["main"] -markers = "implementation_name != \"pypy\"" files = [ {file = "psycopg_binary-3.3.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b7bfff1ca23732b488cbca3076fc11bc98d520ee122514fdb17a8e20d3338f5a"}, {file = "psycopg_binary-3.3.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32a6fbf8481e3a370d0d72b860d35948a693cb01281da217f7b2f307636e591a"}, @@ -508,7 +816,6 @@ version = "3.3.1" description = "Connection Pool for Psycopg" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5"}, {file = "psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c"}, @@ -526,19 +833,28 @@ version = "2.11.1" description = "Python style guide checker" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, ] +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + [[package]] name = "pyflakes" version = "3.1.0" description = "passive checker of Python programs" optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, @@ -550,7 +866,6 @@ version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, @@ -565,7 +880,6 @@ version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.9" -groups = ["dev"] files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -587,7 +901,6 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -602,7 +915,6 @@ version = "1.2.2" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.10" -groups = ["main"] files = [ {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, @@ -611,13 +923,33 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "requests" +version = "2.34.2" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + [[package]] name = "six" version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -groups = ["main"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -629,7 +961,6 @@ version = "0.5.5" description = "A non-validating SQL parser." optional = false python-versions = ">=3.8" -groups = ["main"] files = [ {file = "sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba"}, {file = "sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e"}, @@ -641,36 +972,49 @@ doc = ["sphinx"] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main"] files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] [[package]] name = "tzdata" -version = "2026.2" +version = "2026.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" -groups = ["main"] -markers = "sys_platform == \"win32\"" files = [ - {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, - {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, + {file = "tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"}, + {file = "tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415"}, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.10" +files = [ + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, ] +[package.extras] +brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0)"] + [[package]] name = "wrapt" version = "1.17.3" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = ">=3.8" -groups = ["dev"] files = [ {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"}, {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"}, @@ -756,6 +1100,6 @@ files = [ ] [metadata] -lock-version = "2.1" +lock-version = "2.0" python-versions = "^3.12" -content-hash = "d7efcec7b2416cf8f446ccf1f29a8f990244d72049071d42a3c89dda9f77b97a" +content-hash = "9eb583ae0e1128bbc63179c89a6029c4bd7345c7dc0fafd131d8953a19fcef8c" diff --git a/server/pyproject.toml b/server/pyproject.toml index 8ab8eee..e26f8a9 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -17,6 +17,7 @@ gunicorn = "^23.0.0" python-dotenv = "^1.0.1" django-extensions = "^3.2.3" django-colorfield = "^0.14.0" +mozilla-django-oidc = "^4.0.1" [tool.poetry.group.dev.dependencies] From 466e2d1a8cd74421aeac84fe9e4b36a091251033 Mon Sep 17 00:00:00 2001 From: Dyutiman Date: Sat, 4 Jul 2026 07:44:08 +0000 Subject: [PATCH 02/23] anchoring the static files to its project folder. --- server/api/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/api/settings.py b/server/api/settings.py index 812fc00..10e6a7f 100644 --- a/server/api/settings.py +++ b/server/api/settings.py @@ -159,11 +159,11 @@ def env_bool(name, default=False): STATIC_URL = "/static/" # STATIC_ROOT is where the static files get copied to when "collectstatic" is run. -STATIC_ROOT = "static_files" +STATIC_ROOT = BASE_DIR / "static_files" # This is where to _find_ static files when 'collectstatic' is run. # These files are then copied to the STATIC_ROOT location. -STATICFILES_DIRS = ("static",) +STATICFILES_DIRS = [BASE_DIR / "static"] # The directory to store images and other media MEDIA_ROOT = BASE_DIR/"media" From 9129b7039c0ec77a8b2f112d75f30e0dc5144722 Mon Sep 17 00:00:00 2001 From: Dyutiman Date: Sat, 4 Jul 2026 07:54:02 +0000 Subject: [PATCH 03/23] removed the unnecessary creation of static folder. --- .devcontainer/setup.sh | 3 --- server/static/.gitkeep | 0 2 files changed, 3 deletions(-) create mode 100644 server/static/.gitkeep diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh index d6159c5..5227467 100755 --- a/.devcontainer/setup.sh +++ b/.devcontainer/setup.sh @@ -18,9 +18,6 @@ if [ ! -f ./server/.env ]; then cp ./server/.env.example ./server/.env fi -# Make the Django static folder to remove the annoying warning -(cd server && mkdir -p static) - # Install dependencies (cd server && POETRY_VIRTUALENVS_CREATE=false poetry install) (cd client && npm install) diff --git a/server/static/.gitkeep b/server/static/.gitkeep new file mode 100644 index 0000000..e69de29 From 2cbce9eb111a0e4f7a8e77c9ec2d7e2c5bfacf38 Mon Sep 17 00:00:00 2001 From: nick Date: Sat, 4 Jul 2026 07:42:51 +0000 Subject: [PATCH 04/23] add some unique params --- server/agronomy_club/models.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/agronomy_club/models.py b/server/agronomy_club/models.py index cfc38e3..b2f37ca 100644 --- a/server/agronomy_club/models.py +++ b/server/agronomy_club/models.py @@ -36,13 +36,13 @@ def max_value_curr_year(value): class Chapter(models.Model): id = models.AutoField(primary_key=True, auto_created=True, unique=True) - name = models.CharField(max_length=255) - abbrev = models.CharField(max_length=255) + name = models.CharField(max_length=255, unique=True) + abbrev = models.CharField(max_length=255, unique=True) # Store chapter logos in media/chapter_logos/ directory. Need to also include default logo for when chapter does not provide one. - logo = models.ImageField(upload_to='chapter_logos/', null=True, blank=True, default='chapter_logos/default.png') + logo = models.ImageField(upload_to='chapter_logos/', null=True, blank=True, default='chapter_logos/default.png', unique=True) location = models.CharField(max_length=255) desc = models.TextField(max_length=5000) - email = models.EmailField(max_length=255) + email = models.EmailField(max_length=255, unique=True) colour = ColorField(default=random_color, unique=True, editable=True) # lambda function used to generate new random color def __str__(self): From 646e3608c5cbc7b9e63e373e9b89ce7c2eb4a7c9 Mon Sep 17 00:00:00 2001 From: nick Date: Sun, 5 Jul 2026 01:40:19 +0000 Subject: [PATCH 05/23] change default logo name to the chapter name (help enforce uniqueness) --- server/agronomy_club/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/agronomy_club/models.py b/server/agronomy_club/models.py index b2f37ca..3878288 100644 --- a/server/agronomy_club/models.py +++ b/server/agronomy_club/models.py @@ -39,7 +39,7 @@ class Chapter(models.Model): name = models.CharField(max_length=255, unique=True) abbrev = models.CharField(max_length=255, unique=True) # Store chapter logos in media/chapter_logos/ directory. Need to also include default logo for when chapter does not provide one. - logo = models.ImageField(upload_to='chapter_logos/', null=True, blank=True, default='chapter_logos/default.png', unique=True) + logo = models.ImageField(upload_to='chapter_logos/', null=True, blank=True, default=f'chapter_logo/{name}.png', unique=True) location = models.CharField(max_length=255) desc = models.TextField(max_length=5000) email = models.EmailField(max_length=255, unique=True) From f248e43abda8c81c2908db81123b746fb5be4319 Mon Sep 17 00:00:00 2001 From: nick Date: Sun, 5 Jul 2026 01:40:52 +0000 Subject: [PATCH 06/23] make migration --- ...rs_abbrev_alter_chapters_email_and_more.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py diff --git a/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py b/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py new file mode 100644 index 0000000..b13a4b3 --- /dev/null +++ b/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.15 on 2026-07-05 01:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("agronomy_club", "0007_mock_resource_type_tags"), + ] + + operations = [ + migrations.AlterField( + model_name="chapters", + name="abbrev", + field=models.CharField(max_length=255, unique=True), + ), + migrations.AlterField( + model_name="chapters", + name="email", + field=models.EmailField(max_length=255, unique=True), + ), + migrations.AlterField( + model_name="chapters", + name="logo", + field=models.ImageField( + blank=True, + default="chapter_logo/.png", + null=True, + unique=True, + upload_to="chapter_logos/", + ), + ), + migrations.AlterField( + model_name="chapters", + name="name", + field=models.CharField(max_length=255, unique=True), + ), + ] From 70941a9ba162003dc2817a54b83b4df5e02b16d3 Mon Sep 17 00:00:00 2001 From: nick Date: Sun, 5 Jul 2026 02:03:36 +0000 Subject: [PATCH 07/23] fixed a bug where logo does not take save the chapter name as default path --- ...er_chapters_abbrev_alter_chapters_email_and_more.py | 8 ++------ server/agronomy_club/models.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py b/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py index b13a4b3..7539d03 100644 --- a/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py +++ b/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.15 on 2026-07-05 01:40 +# Generated by Django 5.2.15 on 2026-07-05 02:02 from django.db import migrations, models @@ -24,11 +24,7 @@ class Migration(migrations.Migration): model_name="chapters", name="logo", field=models.ImageField( - blank=True, - default="chapter_logo/.png", - null=True, - unique=True, - upload_to="chapter_logos/", + blank=True, null=True, unique=True, upload_to="chapter_logos/" ), ), migrations.AlterField( diff --git a/server/agronomy_club/models.py b/server/agronomy_club/models.py index 3878288..78b2962 100644 --- a/server/agronomy_club/models.py +++ b/server/agronomy_club/models.py @@ -38,8 +38,8 @@ class Chapter(models.Model): id = models.AutoField(primary_key=True, auto_created=True, unique=True) name = models.CharField(max_length=255, unique=True) abbrev = models.CharField(max_length=255, unique=True) - # Store chapter logos in media/chapter_logos/ directory. Need to also include default logo for when chapter does not provide one. - logo = models.ImageField(upload_to='chapter_logos/', null=True, blank=True, default=f'chapter_logo/{name}.png', unique=True) + # Store chapter logos in media/chapter_logos/ directory. When no logo is uploaded, default to chapter_logos/.png (set in save()). + logo = models.ImageField(upload_to='chapter_logos/', null=True, blank=True, unique=True) location = models.CharField(max_length=255) desc = models.TextField(max_length=5000) email = models.EmailField(max_length=255, unique=True) @@ -48,6 +48,12 @@ class Chapter(models.Model): def __str__(self): return str(self.name) + # Default the logo path to chapter_logos/.png when none is provided. + def save(self, *args, **kwargs): + if not self.logo: + self.logo = f'chapter_logos/{self.name}.png' + return super().save(*args, **kwargs) + class Event(models.Model): """ Model for events information such as title, description, location, date, thumbnail and chapter.""" From ad5e10f21bce8e06d7f04fc5e289fc8f3b1634f7 Mon Sep 17 00:00:00 2001 From: nick Date: Thu, 9 Jul 2026 06:59:04 +0000 Subject: [PATCH 08/23] remade validation for unique non default --- server/agronomy_club/models.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/server/agronomy_club/models.py b/server/agronomy_club/models.py index 78b2962..446f5ac 100644 --- a/server/agronomy_club/models.py +++ b/server/agronomy_club/models.py @@ -38,8 +38,8 @@ class Chapter(models.Model): id = models.AutoField(primary_key=True, auto_created=True, unique=True) name = models.CharField(max_length=255, unique=True) abbrev = models.CharField(max_length=255, unique=True) - # Store chapter logos in media/chapter_logos/ directory. When no logo is uploaded, default to chapter_logos/.png (set in save()). - logo = models.ImageField(upload_to='chapter_logos/', null=True, blank=True, unique=True) + # Store chapter logos in media/chapter_logos/ directory. When no logo is uploaded, default to chapter_logos/default.png. + logo = models.ImageField(upload_to='chapter_logos/', null=True, blank=True, default='chapter_logos/default.png') location = models.CharField(max_length=255) desc = models.TextField(max_length=5000) email = models.EmailField(max_length=255, unique=True) @@ -48,10 +48,27 @@ class Chapter(models.Model): def __str__(self): return str(self.name) - # Default the logo path to chapter_logos/.png when none is provided. - def save(self, *args, **kwargs): + # Unique logo path validation, ignoring the default path + def clean(self): + super().clean() + + # No path to default path if not self.logo: - self.logo = f'chapter_logos/{self.name}.png' + self.logo = 'chapter_logos/default.png' + + if self.logo != 'chapter_logos/default.png': + queryset = Chapters.objects.filter(logo=self.logo) + + # For update queries (object already exist in db) + if self.pk: + queryset = queryset.exclude(pk=self.pk) + + if queryset.exists(): + raise forms.ValidationError('Logo path already exists') + + # Enforce clean in object saves + def save(self, *args, **kwargs): + self.full_clean() return super().save(*args, **kwargs) From 5f2bed4e5a18b52583628749c5ed61a07e4baa1e Mon Sep 17 00:00:00 2001 From: nick Date: Thu, 9 Jul 2026 07:00:39 +0000 Subject: [PATCH 09/23] make migration --- ...lter_chapters_abbrev_alter_chapters_email_and_more.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py b/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py index 7539d03..15a0403 100644 --- a/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py +++ b/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.15 on 2026-07-05 02:02 +# Generated by Django 5.2.15 on 2026-07-09 07:00 from django.db import migrations, models @@ -20,13 +20,6 @@ class Migration(migrations.Migration): name="email", field=models.EmailField(max_length=255, unique=True), ), - migrations.AlterField( - model_name="chapters", - name="logo", - field=models.ImageField( - blank=True, null=True, unique=True, upload_to="chapter_logos/" - ), - ), migrations.AlterField( model_name="chapters", name="name", From 69a1a88f37efb590d34a4a06c4a0008782deb904 Mon Sep 17 00:00:00 2001 From: nick Date: Sat, 11 Jul 2026 02:24:31 +0000 Subject: [PATCH 10/23] replace unique logo logic --- server/agronomy_club/models.py | 31 +++++++++---------------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/server/agronomy_club/models.py b/server/agronomy_club/models.py index 446f5ac..31ac382 100644 --- a/server/agronomy_club/models.py +++ b/server/agronomy_club/models.py @@ -48,28 +48,15 @@ class Chapter(models.Model): def __str__(self): return str(self.name) - # Unique logo path validation, ignoring the default path - def clean(self): - super().clean() - - # No path to default path - if not self.logo: - self.logo = 'chapter_logos/default.png' - - if self.logo != 'chapter_logos/default.png': - queryset = Chapters.objects.filter(logo=self.logo) - - # For update queries (object already exist in db) - if self.pk: - queryset = queryset.exclude(pk=self.pk) - - if queryset.exists(): - raise forms.ValidationError('Logo path already exists') - - # Enforce clean in object saves - def save(self, *args, **kwargs): - self.full_clean() - return super().save(*args, **kwargs) + # Custon unique logo validation, ignoring the default path + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['logo'], + condition=~models.Q(logo='chapter_logos/default.png'), + name='unique_logo_except_default' + ) + ] class Event(models.Model): From 749ca9e5478d975d8d3281efabdd0e44da392389 Mon Sep 17 00:00:00 2001 From: nick Date: Sat, 11 Jul 2026 02:34:21 +0000 Subject: [PATCH 11/23] override save to make none and empty path to default path --- server/agronomy_club/models.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/agronomy_club/models.py b/server/agronomy_club/models.py index 31ac382..d07c192 100644 --- a/server/agronomy_club/models.py +++ b/server/agronomy_club/models.py @@ -48,7 +48,7 @@ class Chapter(models.Model): def __str__(self): return str(self.name) - # Custon unique logo validation, ignoring the default path + # Custom unique logo validation, ignoring the default path class Meta: constraints = [ models.UniqueConstraint( @@ -58,6 +58,13 @@ class Meta: ) ] + # Make null or empty path to default path + def save(self, *args, **kwargs): + if not self.logo: + self.logo = 'chapter_logos/default.png' + + return super().save(*args, **kwargs) + class Event(models.Model): """ Model for events information such as title, description, location, date, thumbnail and chapter.""" From bd8db2600f3f8f466a2953c044a9ae43d66ce7ae Mon Sep 17 00:00:00 2001 From: nick Date: Sat, 11 Jul 2026 02:36:22 +0000 Subject: [PATCH 12/23] make migration --- ..._chapters_abbrev_alter_chapters_email_and_more.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py b/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py index 15a0403..0947167 100644 --- a/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py +++ b/server/agronomy_club/migrations/0008_alter_chapters_abbrev_alter_chapters_email_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.15 on 2026-07-09 07:00 +# Generated by Django 5.2.15 on 2026-07-11 02:35 from django.db import migrations, models @@ -25,4 +25,14 @@ class Migration(migrations.Migration): name="name", field=models.CharField(max_length=255, unique=True), ), + migrations.AddConstraint( + model_name="chapters", + constraint=models.UniqueConstraint( + condition=models.Q( + ("logo", "chapter_logos/default.png"), _negated=True + ), + fields=("logo",), + name="unique_logo_except_default", + ), + ), ] From dcc934bac7aff42ab2c9fa5737789b5a85129c1f Mon Sep 17 00:00:00 2001 From: Anthony Do Date: Sat, 11 Jul 2026 07:33:31 +0000 Subject: [PATCH 13/23] added API endpoint for self-service user signup/signin and usage documentation --- README.md | 76 ++++++++ server/agronomy_club/admin.py | 6 +- ...ser_password_and_membership_constraints.py | 42 +++++ server/agronomy_club/models.py | 53 ++++-- server/agronomy_club/serializers.py | 29 ++- server/agronomy_club/tests.py | 177 ++++++++++++++++-- server/agronomy_club/urls.py | 4 + server/agronomy_club/views.py | 113 +++++++++-- 8 files changed, 456 insertions(+), 44 deletions(-) create mode 100644 server/agronomy_club/migrations/0009_user_password_and_membership_constraints.py diff --git a/README.md b/README.md index 5ceba41..b0a3753 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,82 @@ python manage.py migrate # apply migrations If you run into migration conflicts that you can't be bothered to fix, run `nuke.sh` to clear your database. Then, run migrations again. +### User Self-Service Auth API + +The backend provides frontend-facing endpoints for normal user signup and login. + +Base URL (dev): `http://localhost:8000/api` + +1. Signup + +Endpoint: +`POST /auth/signup/` + +Request body: +```json +{ + "full_name": "Jane Example", + "grad_yr": 2031, + "discipline": "Agronomy", + "email": "jane@example.com", + "password": "StrongPass#2026" +} +``` + +Success response (`201 Created`): +```json +{ + "message": "Signup successful.", + "user": { + "id": 1, + "full_name": "Jane Example", + "grad_yr": 2031, + "discipline": "Agronomy", + "email": "jane@example.com", + "global_role": "user" + } +} +``` + +Common error responses: +- `400 Bad Request` for invalid payload, weak/short password, duplicate email, or invalid graduation year. + +2. Login + +Endpoint: +`POST /auth/login/` + +Request body: +```json +{ + "email": "jane@example.com", + "password": "StrongPass#2026" +} +``` + +Success response (`200 OK`): +```json +{ + "message": "Login successful.", + "user": { + "id": 1, + "full_name": "Jane Example", + "grad_yr": 2031, + "discipline": "Agronomy", + "email": "jane@example.com", + "global_role": "user" + } +} +``` + +Common error responses: +- `401 Unauthorized` when email/password is incorrect. +- `400 Bad Request` for malformed payload. + +Notes: +- These endpoints are intended for normal users (not Django admin/Keycloak admin). +- Passwords are stored as hashes in backend storage. + ## Other ### Keycloak Deployment diff --git a/server/agronomy_club/admin.py b/server/agronomy_club/admin.py index 38d5b87..59eb562 100644 --- a/server/agronomy_club/admin.py +++ b/server/agronomy_club/admin.py @@ -1,9 +1,9 @@ from django.contrib import admin # noqa -from agronomy_club.models import ChapterMemberships, Users, Quiz, Chapters, Resource, ResourceTypeTag, Event # noqa +from agronomy_club.models import ChapterMemberships, User, Quiz, Chapter, Resource, ResourceTypeTag, Event # noqa # Register your models here. -@admin.register(Chapters) +@admin.register(Chapter) class ChaptersAdmin(admin.ModelAdmin): list_display = ('id', 'name', 'abbrev', 'location', 'email') search_fields = ('id', 'name', 'abbrev', 'location') @@ -36,7 +36,7 @@ class EventAdmin(admin.ModelAdmin): list_filter = ('chapter',) -@admin.register(Users) +@admin.register(User) class UsersAdmin(admin.ModelAdmin): list_display = ('id', 'full_name', 'grad_yr', 'discipline', 'email', 'global_role') search_fields = ('id', 'full_name', 'discipline',) diff --git a/server/agronomy_club/migrations/0009_user_password_and_membership_constraints.py b/server/agronomy_club/migrations/0009_user_password_and_membership_constraints.py new file mode 100644 index 0000000..e54c727 --- /dev/null +++ b/server/agronomy_club/migrations/0009_user_password_and_membership_constraints.py @@ -0,0 +1,42 @@ +# Generated manually to avoid interactive rename prompts. + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("agronomy_club", "0008_alter_chapters_abbrev_alter_chapters_email_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="users", + name="password_hash", + field=models.CharField(blank=True, default="", max_length=255), + ), + migrations.AlterField( + model_name="chaptermemberships", + name="position", + field=models.CharField( + blank=True, + choices=[ + ("pres", "President"), + ("vpres", "Vice President"), + ("sec", "Secretary"), + ("treas", "Treasurer"), + ("mark", "Marketing Officer"), + ("ocm", "Ordinary Committee Member"), + ], + default="", + max_length=100, + ), + ), + migrations.AddConstraint( + model_name="chaptermemberships", + constraint=models.UniqueConstraint( + fields=("user_id", "chapter_id"), + name="unique_user_membership_per_chapter", + ), + ), + ] diff --git a/server/agronomy_club/models.py b/server/agronomy_club/models.py index d07c192..a7c8a33 100644 --- a/server/agronomy_club/models.py +++ b/server/agronomy_club/models.py @@ -3,6 +3,7 @@ import datetime # noqa from django.core.validators import MaxValueValidator, MinValueValidator # noqa from django import forms +from django.contrib.auth.hashers import check_password, make_password # Model for Chapter information such as logo, location, description and email. Chapter members should be stored in a seperate model. # This model uses django-colorfield to store the colour of the chapter as well as provide a color picker widget in admin panel. @@ -50,6 +51,7 @@ def __str__(self): # Custom unique logo validation, ignoring the default path class Meta: + db_table = 'agronomy_club_chapters' constraints = [ models.UniqueConstraint( fields=['logo'], @@ -118,10 +120,22 @@ class User(models.Model): discipline = models.CharField(max_length=100) email = models.EmailField(max_length=255, unique=True) global_role = models.CharField(max_length=100, choices=[('admin', 'Admin'), ('alumni', 'Alumni'), ('user', 'User')], default='user') + password_hash = models.CharField(max_length=255, blank=True, default='') + + class Meta: + db_table = 'agronomy_club_users' def __str__(self): return f"{self.full_name} - {self.global_role}" + def set_password(self, raw_password): + self.password_hash = make_password(raw_password) + + def check_password(self, raw_password): + if not self.password_hash: + return False + return check_password(raw_password, self.password_hash) + class ChapterMemberships(models.Model): id = models.AutoField(primary_key=True, auto_created=True, unique=True) @@ -130,33 +144,44 @@ class ChapterMemberships(models.Model): chapter_id = models.ForeignKey(Chapter, on_delete=models.CASCADE, related_name='chapter_memberships') position = models.CharField(max_length=100, choices=[('pres', 'President'), ('vpres', 'Vice President'), ('sec', 'Secretary'), ('treas', 'Treasurer'), ('mark', 'Marketing Officer'), - ('ocm', 'Ordinary Committee Member')], default='pres', blank=True) + ('ocm', 'Ordinary Committee Member')], default='', blank=True) def __str__(self): return f"{self.user_id.full_name} - {self.chapter_id.name} - {self.chapter_role}" + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['user_id', 'chapter_id'], + name='unique_user_membership_per_chapter', + ) + ] + # runs after a change to an object is submitted on the dashboard, performing validation checks # it has been overridden to include more validation for the different chapter roles # admins and owners must have a valid position, whereas members cannot have a position def clean(self): super().clean() - if self.chapter_role not in ('admin', 'owner'): - if self.chapter_role != 'member': - raise forms.ValidationError("Invalid Chapter Role") - if self.position != '': + + valid_roles = {'member', 'admin', 'owner'} + valid_positions = {'pres', 'vpres', 'sec', 'treas', 'mark', 'ocm'} + + if self.chapter_role not in valid_roles: + raise forms.ValidationError("Invalid Chapter Role") + + if self.chapter_role == 'member': + if self.position not in ('', None): raise forms.ValidationError("Chapter Members cannot have a Committee Position. They must be an Admin or Owner to have one.") self.position = '' - else: - if self.position not in ('pres', 'vpres', 'sec', 'treas', 'mark', 'ocm'): - raise forms.ValidationError("Invalid Chapter Committee Position. A chapter Admin or Owner must have a valid Committee role.") - super().clean() + return + + if self.position not in valid_positions: + raise forms.ValidationError("Invalid Chapter Committee Position. A chapter Admin or Owner must have a valid Committee role.") # runs after clean() and immediately before the database is written to, in essence the 'last line of defense' for validation checks # has been overridden to include sanitisation of the position field as a last check def save(self, *args, **kwargs): - if self.chapter_role not in ('admin', 'owner'): - self.position = '' - else: - if self.position not in ('pres', 'vpres', 'sec', 'treas', 'mark', 'ocm'): - raise forms.ValidationError("Invalid Chapter Committee Position. A chapter Admin or Owner must have a valid Committee role.") + self.full_clean() return super().save(*args, **kwargs) + + diff --git a/server/agronomy_club/serializers.py b/server/agronomy_club/serializers.py index f0e97ef..afac7f3 100644 --- a/server/agronomy_club/serializers.py +++ b/server/agronomy_club/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import Quiz, Resource, ResourceTypeTag, Event, Users +from .models import Quiz, Resource, ResourceTypeTag, Event, User class QuizDataSerializer(serializers.ModelSerializer): @@ -41,7 +41,7 @@ class Meta: class AlumniSerializer(serializers.ModelSerializer): class Meta: - model = Users + model = User exclude = ['global_role'] @@ -72,3 +72,28 @@ class Meta: "chapterName", "upload_date", ] + + +class NormalUserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "full_name", "grad_yr", "discipline", "email", "global_role"] + + +class UserSignupSerializer(serializers.Serializer): + full_name = serializers.CharField(max_length=100) + grad_yr = serializers.IntegerField() + discipline = serializers.CharField(max_length=100) + email = serializers.EmailField(max_length=255) + password = serializers.CharField(write_only=True, min_length=8, trim_whitespace=False) + + def validate_email(self, value): + lowered = value.strip().lower() + if User.objects.filter(email__iexact=lowered).exists(): + raise serializers.ValidationError("A user with this email already exists.") + return lowered + + +class UserLoginSerializer(serializers.Serializer): + email = serializers.EmailField(max_length=255) + password = serializers.CharField(write_only=True, trim_whitespace=False) diff --git a/server/agronomy_club/tests.py b/server/agronomy_club/tests.py index d550a02..05ce27c 100644 --- a/server/agronomy_club/tests.py +++ b/server/agronomy_club/tests.py @@ -10,23 +10,23 @@ from rest_framework.test import APITestCase from datetime import datetime -from agronomy_club.models import Users, max_value_curr_year, Resource, ResourceTypeTag, Chapters, Event +from agronomy_club.models import User, max_value_curr_year, Resource, ResourceTypeTag, Chapter, Event, ChapterMemberships class UserModelSmokeTests(TestCase): def tearDown(self): - Users.objects.all().delete() + User.objects.all().delete() super().tearDown() def test_can_create_and_read_user(self): - user = Users.objects.create( + user = User.objects.create( full_name="Ada Lovelace", grad_yr=2030, discipline="Agronomy", email="ada@example.com", ) - saved_user = Users.objects.get(pk=user.pk) + saved_user = User.objects.get(pk=user.pk) self.assertEqual(saved_user.full_name, "Ada Lovelace") self.assertEqual(saved_user.email, "ada@example.com") @@ -34,7 +34,7 @@ def test_can_create_and_read_user(self): self.assertEqual(str(saved_user), "Ada Lovelace - user") def test_rejects_duplicate_email(self): - Users.objects.create( + User.objects.create( full_name="Ada Lovelace", grad_yr=2030, discipline="Agronomy", @@ -44,7 +44,7 @@ def test_rejects_duplicate_email(self): with self.assertRaises(IntegrityError): with transaction.atomic(): - Users.objects.create( + User.objects.create( full_name="Grace Hopper", grad_yr=2031, discipline="Soil Science", @@ -53,7 +53,7 @@ def test_rejects_duplicate_email(self): ) def test_rejects_invalid_graduation_year_on_clean(self): - user = Users( + user = User( full_name="Bad Year", grad_yr=1899, discipline="Agronomy", @@ -67,7 +67,7 @@ def test_rejects_invalid_graduation_year_on_clean(self): class EventModelSmokeTests(TestCase): def setUp(self): - self.chapter = Chapters.objects.create( + self.chapter = Chapter.objects.create( name="Perth Chapter", abbrev="PER", location="Perth", @@ -138,7 +138,7 @@ def setUp(self): self._existing_tag_ids = list( ResourceTypeTag.objects.values_list('pk', flat=True) ) - self.chapter = Chapters.objects.create( + self.chapter = Chapter.objects.create( name='gamers', abbrev='game', location='Amphoreus', @@ -148,7 +148,7 @@ def setUp(self): def tearDown(self): Resource.objects.all().delete() - Chapters.objects.all().delete() + Chapter.objects.all().delete() ResourceTypeTag.objects.exclude(pk__in=self._existing_tag_ids).delete() super().tearDown() @@ -236,7 +236,7 @@ def setUp(self): self._existing_tag_ids = list( ResourceTypeTag.objects.values_list('pk', flat=True) ) - self.chapter = Chapters.objects.create( + self.chapter = Chapter.objects.create( name='gamers', abbrev='game', location='Amphoreus', @@ -283,7 +283,7 @@ def setUp(self): def tearDown(self): Resource.objects.all().delete() - Chapters.objects.all().delete() + Chapter.objects.all().delete() ResourceTypeTag.objects.exclude(pk__in=self._existing_tag_ids).delete() super().tearDown() @@ -336,3 +336,156 @@ def test_list_resource_tags(self): self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json()), 17) + + +class AuthAPITests(APITestCase): + def test_signup_creates_normal_user(self): + response = self.client.post( + reverse('auth-signup'), + { + 'full_name': 'Normal User', + 'grad_yr': 2031, + 'discipline': 'Agronomy', + 'email': 'normal@example.com', + 'password': 'StrongPass#2026', + }, + format='json', + ) + + self.assertEqual(response.status_code, 201) + user = User.objects.get(email='normal@example.com') + self.assertEqual(user.global_role, 'user') + self.assertTrue(user.check_password('StrongPass#2026')) + + def test_signup_rejects_duplicate_email(self): + existing = User.objects.create( + full_name='Existing', + grad_yr=2030, + discipline='Agronomy', + email='existing@example.com', + global_role='user', + ) + existing.set_password('StrongPass#2026') + existing.save() + + response = self.client.post( + reverse('auth-signup'), + { + 'full_name': 'Another', + 'grad_yr': 2031, + 'discipline': 'Agronomy', + 'email': 'existing@example.com', + 'password': 'StrongPass#2026', + }, + format='json', + ) + + self.assertEqual(response.status_code, 400) + + def test_login_succeeds_with_valid_credentials(self): + user = User.objects.create( + full_name='Login User', + grad_yr=2030, + discipline='Agronomy', + email='login@example.com', + global_role='user', + ) + user.set_password('StrongPass#2026') + user.save() + + response = self.client.post( + reverse('auth-login'), + {'email': 'login@example.com', 'password': 'StrongPass#2026'}, + format='json', + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['user']['email'], 'login@example.com') + + def test_login_rejects_invalid_credentials(self): + user = User.objects.create( + full_name='Login User', + grad_yr=2030, + discipline='Agronomy', + email='login2@example.com', + global_role='user', + ) + user.set_password('StrongPass#2026') + user.save() + + response = self.client.post( + reverse('auth-login'), + {'email': 'login2@example.com', 'password': 'wrong-password'}, + format='json', + ) + + self.assertEqual(response.status_code, 401) + + +class ChapterMembershipRequirementsTests(TestCase): + def setUp(self): + self.user = User.objects.create( + full_name='Member User', + grad_yr=2030, + discipline='Agronomy', + email='member@example.com', + global_role='user', + ) + self.chapter = Chapter.objects.create( + name='Membership Chapter', + abbrev='MEM', + location='Perth', + desc='Membership test chapter', + email='membership@agronomy.club', + colour='#bbaa11', + ) + + def test_member_cannot_have_committee_position(self): + membership = ChapterMemberships( + user_id=self.user, + chapter_id=self.chapter, + chapter_role='member', + position='pres', + ) + + with self.assertRaises(ValidationError): + membership.full_clean() + + def test_admin_requires_valid_committee_position(self): + membership = ChapterMemberships( + user_id=self.user, + chapter_id=self.chapter, + chapter_role='admin', + position='', + ) + + with self.assertRaises(ValidationError): + membership.full_clean() + + def test_owner_with_valid_position_is_accepted(self): + membership = ChapterMemberships.objects.create( + user_id=self.user, + chapter_id=self.chapter, + chapter_role='owner', + position='pres', + ) + + self.assertEqual(membership.chapter_role, 'owner') + self.assertEqual(membership.position, 'pres') + + def test_unique_membership_per_user_and_chapter(self): + ChapterMemberships.objects.create( + user_id=self.user, + chapter_id=self.chapter, + chapter_role='member', + position='', + ) + + with self.assertRaises(ValidationError): + duplicate = ChapterMemberships( + user_id=self.user, + chapter_id=self.chapter, + chapter_role='admin', + position='pres', + ) + duplicate.full_clean() diff --git a/server/agronomy_club/urls.py b/server/agronomy_club/urls.py index 4c2c26c..98ff4f8 100644 --- a/server/agronomy_club/urls.py +++ b/server/agronomy_club/urls.py @@ -5,6 +5,10 @@ urlpatterns = [ path("ping/", views.ping, name="ping"), + # Auth + path("auth/signup/", views.UserSignupAPIView.as_view(), name="auth-signup"), + path("auth/login/", views.UserLoginAPIView.as_view(), name="auth-login"), + # Quiz path("quizzes/download//", views.QuizDataAPIView.as_view(), name="quiz-download"), diff --git a/server/agronomy_club/views.py b/server/agronomy_club/views.py index d9bec64..9e3729c 100644 --- a/server/agronomy_club/views.py +++ b/server/agronomy_club/views.py @@ -1,6 +1,21 @@ from rest_framework import generics -from .serializers import QuizSerializer, QuizDataSerializer, ResourceSerializer, ResourceTypeTagSerializer, EventListSerializer, AlumniSerializer -from .models import Resource, ResourceTypeTag, Users, Event, Quiz +from typing import Any, cast +from django.db.models import QuerySet +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView +from .serializers import ( + QuizSerializer, + QuizDataSerializer, + ResourceSerializer, + ResourceTypeTagSerializer, + EventListSerializer, + AlumniSerializer, + UserSignupSerializer, + UserLoginSerializer, + NormalUserSerializer, +) +from .models import Resource, ResourceTypeTag, User, Event, Quiz from rest_framework.decorators import api_view from django.http import HttpResponse @@ -12,11 +27,12 @@ def ping(request): class QuizDataAPIView(generics.RetrieveAPIView): + queryset = Quiz.objects.none() serializer_class = QuizDataSerializer lookup_field = "id" - def get_queryset(self): - return Quiz.objects.filter(public=True) + def get_queryset(self) -> QuerySet[Quiz]: # type: ignore[override] + return Quiz.objects.filter(public=True).all() class ResourceTypeTagListAPIView(generics.ListAPIView): @@ -25,24 +41,26 @@ class ResourceTypeTagListAPIView(generics.ListAPIView): class ResourceListAPIView(generics.ListAPIView): + queryset = Resource.objects.none() serializer_class = ResourceSerializer - def get_queryset(self): - self.queryset = Resource.objects.all().order_by("-upload_date") + def get_queryset(self) -> QuerySet[Resource]: # type: ignore[override] + queryset = Resource.objects.all().order_by("-upload_date") tags = self.request.GET.get("tags") if tags: tag_ids = [t for t in tags.split(",") if t.strip().isdigit()] if tag_ids: - self.queryset = self.queryset.filter(type_tags__id__in=tag_ids).distinct() + queryset = queryset.filter(type_tags__id__in=tag_ids).distinct() - return self.queryset + return queryset class AlumniListAPIView(generics.ListAPIView): + queryset = User.objects.none() serializer_class = AlumniSerializer - def get_queryset(self): - return Users.objects.filter(global_role="alumni").order_by("-grad_yr") + def get_queryset(self) -> QuerySet[User]: # type: ignore[override] + return User.objects.filter(global_role="alumni").order_by("-grad_yr").all() class EventListAPIView(generics.ListAPIView): @@ -50,9 +68,10 @@ class EventListAPIView(generics.ListAPIView): GET /api/events/ Returns a list of events. """ + queryset = Event.objects.none() serializer_class = EventListSerializer - def get_queryset(self): + def get_queryset(self) -> QuerySet[Event]: # type: ignore[override] return Event.objects.all().order_by("-date") @@ -61,7 +80,75 @@ class QuizListAPIView(generics.ListAPIView): GET /api/quizzes/ Returns a list of quizzes """ + queryset = Quiz.objects.none() serializer_class = QuizSerializer - def get_queryset(self): - return Quiz.objects.filter(public=True).order_by("-upload_date") + def get_queryset(self) -> QuerySet[Quiz]: # type: ignore[override] + return Quiz.objects.filter(public=True).order_by("-upload_date").all() + + +class UserSignupAPIView(APIView): + """ + POST /api/auth/signup/ + Creates a normal user account for frontend authentication. + """ + + authentication_classes = [] + permission_classes = [] + + def post(self, request): + serializer = UserSignupSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + validated = cast(dict[str, Any], serializer.validated_data) + + user = User( + full_name=validated["full_name"], + grad_yr=validated["grad_yr"], + discipline=validated["discipline"], + email=validated["email"], + global_role="user", + ) + user.set_password(validated["password"]) + user.full_clean() + user.save() + + return Response( + { + "message": "Signup successful.", + "user": NormalUserSerializer(user).data, + }, + status=status.HTTP_201_CREATED, + ) + + +class UserLoginAPIView(APIView): + """ + POST /api/auth/login/ + Authenticates a normal user by email and password. + """ + + authentication_classes = [] + permission_classes = [] + + def post(self, request): + serializer = UserLoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + validated = cast(dict[str, Any], serializer.validated_data) + + email = validated["email"].strip().lower() + password = validated["password"] + + user = User.objects.filter(email__iexact=email).first() + if user is None or not user.check_password(password): + return Response( + {"detail": "Invalid email or password."}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + return Response( + { + "message": "Login successful.", + "user": NormalUserSerializer(user).data, + }, + status=status.HTTP_200_OK, + ) From 60ef6e9e176e99db29992ff9683c7ce654e5cd82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:47:11 +0000 Subject: [PATCH 14/23] fix: add db_table to Chapter.Meta to match existing DB table name --- server/agronomy_club/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server/agronomy_club/models.py b/server/agronomy_club/models.py index 1399bb9..a7c8a33 100644 --- a/server/agronomy_club/models.py +++ b/server/agronomy_club/models.py @@ -51,6 +51,7 @@ def __str__(self): # Custom unique logo validation, ignoring the default path class Meta: + db_table = 'agronomy_club_chapters' constraints = [ models.UniqueConstraint( fields=['logo'], From 4433ff09f875b0449e989baa8f12f6edb9edbdf7 Mon Sep 17 00:00:00 2001 From: Anthony Do Date: Sat, 11 Jul 2026 07:52:47 +0000 Subject: [PATCH 15/23] linting and database migration fix --- ...ters_chapter_rename_users_user_and_more.py | 29 +++++++++++++++++++ server/agronomy_club/models.py | 4 +-- 2 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 server/agronomy_club/migrations/0010_rename_chapters_chapter_rename_users_user_and_more.py diff --git a/server/agronomy_club/migrations/0010_rename_chapters_chapter_rename_users_user_and_more.py b/server/agronomy_club/migrations/0010_rename_chapters_chapter_rename_users_user_and_more.py new file mode 100644 index 0000000..cff8c6a --- /dev/null +++ b/server/agronomy_club/migrations/0010_rename_chapters_chapter_rename_users_user_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.16 on 2026-07-11 07:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("agronomy_club", "0009_user_password_and_membership_constraints"), + ] + + operations = [ + migrations.RenameModel( + old_name="Chapters", + new_name="Chapter", + ), + migrations.RenameModel( + old_name="Users", + new_name="User", + ), + migrations.AlterModelTable( + name="chapter", + table="agronomy_club_chapters", + ), + migrations.AlterModelTable( + name="user", + table="agronomy_club_users", + ), + ] diff --git a/server/agronomy_club/models.py b/server/agronomy_club/models.py index a7c8a33..aa1a042 100644 --- a/server/agronomy_club/models.py +++ b/server/agronomy_club/models.py @@ -182,6 +182,4 @@ def clean(self): # has been overridden to include sanitisation of the position field as a last check def save(self, *args, **kwargs): self.full_clean() - return super().save(*args, **kwargs) - - + return super().save(*args, **kwargs) \ No newline at end of file From d8f0716453a91f37920785d5852b2f25bee636f0 Mon Sep 17 00:00:00 2001 From: Anthony Do Date: Sat, 11 Jul 2026 07:54:51 +0000 Subject: [PATCH 16/23] more linting --- server/agronomy_club/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/agronomy_club/models.py b/server/agronomy_club/models.py index aa1a042..01b67f7 100644 --- a/server/agronomy_club/models.py +++ b/server/agronomy_club/models.py @@ -182,4 +182,4 @@ def clean(self): # has been overridden to include sanitisation of the position field as a last check def save(self, *args, **kwargs): self.full_clean() - return super().save(*args, **kwargs) \ No newline at end of file + return super().save(*args, **kwargs) From e1a8750ed28b8c452463dce945073d7e2dcf3343 Mon Sep 17 00:00:00 2001 From: Anthony Do <140363490+zephris@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:58:23 +0800 Subject: [PATCH 17/23] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- server/api/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/urls.py b/server/api/urls.py index a54eb43..9d75fe0 100644 --- a/server/api/urls.py +++ b/server/api/urls.py @@ -28,7 +28,6 @@ AUTH_SOURCE = os.environ.get("AUTH_SOURCE", "local").lower() urlpatterns = [ - path("admin/login/", admin_login_via_oidc, name="admin-login-via-oidc"), path("admin/", admin.site.urls), path("api/healthcheck/", include(("api.healthcheck.urls"))), path("api/", include(("agronomy_club.urls"))), @@ -36,3 +35,4 @@ if AUTH_SOURCE == "keycloak": urlpatterns.insert(0, path("oidc/", include("mozilla_django_oidc.urls"))) + urlpatterns.insert(0, path("admin/login/", admin_login_via_oidc, name="admin-login-via-oidc")) From e934f02c94da93e3fb7542fcda7508b84e15949a Mon Sep 17 00:00:00 2001 From: Anthony Do <140363490+zephris@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:03:55 +0800 Subject: [PATCH 18/23] Revert "Merge branch 'main' into issue-55-User_Identity_and_Access_Management_system_implementation" This reverts commit f0095fcfe06418e13bbd96f4ca5b5a2517e55ebf, reversing changes made to d8f0716453a91f37920785d5852b2f25bee636f0. --- client/src/app/about/page.tsx | 46 +--- client/src/app/quizzes/page.tsx | 214 ++++++++++++------ .../src/components/committee-member-card.tsx | 38 ---- .../src/components/quiz-instruction-modal.tsx | 84 ------- client/src/components/quiz-list-item.tsx | 2 + client/src/components/ui/ResourceListItem.tsx | 41 ---- server/agronomy_club/admin.py | 1 + server/agronomy_club/serializers.py | 23 +- server/agronomy_club/tests.py | 170 +------------- server/agronomy_club/urls.py | 3 - server/agronomy_club/views.py | 22 +- server/api/urls.py | 2 - server/pyproject.toml | 1 - 13 files changed, 173 insertions(+), 474 deletions(-) delete mode 100644 client/src/components/committee-member-card.tsx delete mode 100644 client/src/components/quiz-instruction-modal.tsx delete mode 100644 client/src/components/ui/ResourceListItem.tsx diff --git a/client/src/app/about/page.tsx b/client/src/app/about/page.tsx index b727a8c..b712ef4 100644 --- a/client/src/app/about/page.tsx +++ b/client/src/app/about/page.tsx @@ -1,28 +1,21 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { CommitteeMemberCard } from "@/components/committee-member-card"; - export const metadata: Metadata = { title: "About | Agronomy Club", description: "Discover the purpose, values, and impact strategy guiding the Agronomy Club.", }; -const committeeMembers = [ - { name: "Member 1", position: "President", photo: "" }, - { name: "Member 2", position: "Vice-President", photo: "" }, - { name: "Member 3", position: "Secretary", photo: "" }, - { name: "Member 4", position: "Treasurer", photo: "" }, -]; + export default function AboutPage() { return (
-
+
-

+

Our purpose

-

+

Growing resilient food systems through collaboration

@@ -32,11 +25,11 @@ export default function AboutPage() { accelerate sustainable agricultural impact.

-
-

Our pillars

+
+

Our Pillars

  • - ⚙️ Research Innovation — Translating agronomic science into + ⚙️ Research & Innovation — Translating agronomic science into applied solutions.
  • @@ -53,31 +46,6 @@ export default function AboutPage() {
-
-

- The Committee -

- {/*

The Committee

*/} -
-

- Current Agronomy Club Committee -

-

- Meet the current committee supporting the club, its members, and - student-led activities. -

-
- {committeeMembers.map((member) => ( - - ))} -
-
-
); diff --git a/client/src/app/quizzes/page.tsx b/client/src/app/quizzes/page.tsx index 41fc182..ee4d53e 100644 --- a/client/src/app/quizzes/page.tsx +++ b/client/src/app/quizzes/page.tsx @@ -1,15 +1,7 @@ -import { - ArrowRight, - CircleCheck, - CircleX, - PartyPopper, - Timer, - Zap, -} from "lucide-react"; +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; -import { QuizInstructionModal } from "@/components/quiz-instruction-modal"; import { QuizListItem } from "@/components/quiz-list-item"; -import { Button } from "@/components/ui/button"; export const metadata = { title: "Quizzes | Agronomy Club", @@ -25,10 +17,20 @@ export default function QuizzesPage() { return (
+ {/* Header */} +
+

Interactive Quizzes

+

+ Challenge your agricultural knowledge with real-time interactive + quizzes. Test yourself, compete with others, and see where you stand + on the leaderboard! +

+
+ {/* Main CTA Card */} -
-
-
+
+
+

Ready to Test Your Knowledge?

@@ -36,69 +38,151 @@ export default function QuizzesPage() { Join live quiz sessions with other members. Answer questions in real-time, get instant feedback, and climb the leaderboard!

- - + Open Quiz-Mate + +
+
+
+
🎯
+

+ Live Interactive Quizzes +

+
+
+
+
-
diff --git a/client/src/app/quizzes/page.tsx b/client/src/app/quizzes/page.tsx index d7a3782..ee4d53e 100644 --- a/client/src/app/quizzes/page.tsx +++ b/client/src/app/quizzes/page.tsx @@ -2,10 +2,6 @@ import { ArrowRight } from "lucide-react"; import Link from "next/link"; import { QuizListItem } from "@/components/quiz-list-item"; -import { QuizInstructionModal } from "@/components/quiz-instruction-modal"; -import { Button } from "@/components/ui/button"; - -import QuizzesClient from "./quizzes-client"; export const metadata = { title: "Quizzes | Agronomy Club", @@ -168,8 +164,32 @@ export default function QuizzesPage() { -

Quizzes

- + + {/* Tips */} +
+

💡 Tips

+
    +
  • + • For Hosts: Set a time limit for each question + to increase engagement +
  • +
  • + • For Players: Answer as quickly and accurately + as possible to win +
  • +
  • + • Everyone: Check out the leaderboard to see how + you compare +
  • +
+
+ ); diff --git a/client/src/app/quizzes/quizzes-client.tsx b/client/src/app/quizzes/quizzes-client.tsx deleted file mode 100644 index 3f48772..0000000 --- a/client/src/app/quizzes/quizzes-client.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { QuizListItem } from "@/components/quiz-list-item"; -import { useQuizzes } from "@/hooks/useQuizzes"; - -export default function QuizzesClient() { - const { data: quizzes = [], isLoading, isError, error } = useQuizzes(); - - if (isLoading) { - return

Quizzes are loading...

; - } - - if (isError) { - console.log(error); - - return ( -

- Error loading quizzes. If refreshing doesn't work, please contact an - administrator. -

- ); - } - - return ( -
- {quizzes?.length > 0 ? ( - quizzes.map((quiz) => ( -
- -
- )) - ) : ( -

No quizzes available.

- )} -
- ); -} diff --git a/client/src/components/quiz-list-item.tsx b/client/src/components/quiz-list-item.tsx index b9d612d..0b920bb 100644 --- a/client/src/components/quiz-list-item.tsx +++ b/client/src/components/quiz-list-item.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React from "react"; import { Button } from "@/components/ui/button"; import { @@ -47,17 +47,6 @@ function QuizListItem({ uploadDate, downloadUrl, }: QuizListItemProps) { - const [displayDate, setDisplayDate] = useState(""); - - useEffect(() => { - setDisplayDate( - new Date(uploadDate).toLocaleString(undefined, { - dateStyle: "medium", - timeStyle: "short", - }), - ); - }, [uploadDate]); - return (
@@ -76,7 +65,7 @@ function QuizListItem({ uploaded on - {displayDate} + {uploadDate} @@ -91,9 +80,7 @@ function QuizListItem({ asChild className="bg-brand-green text-brand-surface transition-opacity hover:bg-brand-green hover:text-white hover:opacity-80" > - - Download - + Download diff --git a/client/src/components/ui/footer.tsx b/client/src/components/ui/footer.tsx deleted file mode 100644 index a843206..0000000 --- a/client/src/components/ui/footer.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import Link from "next/link"; - -export function Footer() { - return ( -
-
-
-
-

- Agronomy Club -

-

- Dedicated to the science of soil management and crop production - for a sustainable world. -

-
-
-

- Menu -

-
    -
  • - - About - -
  • -
  • - - Chapters - -
  • -
  • - - Events - -
  • -
  • - - Quizzes - -
  • -
  • - - Resources - -
  • -
  • - - Alumni - -
  • -
-
-
-

- Connect -

- -
-
-
-
-

- © 2026 Agronomy Club. Cultivating Excellence. -

-
    -
  • - - Privacy Policy - -
  • -
  • - - Terms of Service - -
  • -
-
-
-
-
- ); -} diff --git a/client/src/components/ui/navbar.tsx b/client/src/components/ui/navbar.tsx index 5f85590..cef9dae 100644 --- a/client/src/components/ui/navbar.tsx +++ b/client/src/components/ui/navbar.tsx @@ -31,9 +31,9 @@ export default function Navbar() {
- + Agronomy Club
{/* Mobile menu */} -
+