From 115c5ae3195fbf764cdf31bb67cce4081858c21c Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Thu, 2 Jul 2026 06:19:45 -0500 Subject: [PATCH] Dockerize Django app with dev/beta/prod env config and uv. Replace hardcoded settings with environment-driven config, add Docker compose for local and production deploys, migrate from pip to uv, and split Gitea workflows so PRs run tests only while master pushes deploy. --- .dockerignore | 11 + .env.example | 37 ++ .env.prod.example | 41 ++ .gitea/workflows/ci.yml | 28 ++ .gitea/workflows/deploy.yml | 44 +++ Dockerfile | 28 ++ README.md | 58 ++- company_site/company_site/settings.py | 164 -------- .../company_site/settings/__init__.py | 12 + company_site/company_site/settings/base.py | 172 +++++++++ company_site/company_site/settings/beta.py | 14 + company_site/company_site/settings/dev.py | 15 + company_site/company_site/settings/logging.py | 58 +++ company_site/company_site/settings/prod.py | 12 + docker-compose.prod.yml | 30 ++ docker-compose.yml | 31 ++ pyproject.toml | 26 ++ requirements.txt | 20 - scripts/deploy.sh | 66 +++- scripts/docker-entrypoint.sh | 48 +++ scripts/validate-env.sh | 59 +++ uv.lock | 363 ++++++++++++++++++ 22 files changed, 1139 insertions(+), 198 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .env.prod.example create mode 100644 .gitea/workflows/ci.yml create mode 100644 Dockerfile delete mode 100644 company_site/company_site/settings.py create mode 100644 company_site/company_site/settings/__init__.py create mode 100644 company_site/company_site/settings/base.py create mode 100644 company_site/company_site/settings/beta.py create mode 100644 company_site/company_site/settings/dev.py create mode 100644 company_site/company_site/settings/logging.py create mode 100644 company_site/company_site/settings/prod.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 pyproject.toml delete mode 100644 requirements.txt create mode 100755 scripts/docker-entrypoint.sh create mode 100755 scripts/validate-env.sh create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f73a357 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.venv +**/__pycache__ +*.py[cod] +db.sqlite3 +.env +htmlcov/ +.pytest_cache/ +.mypy_cache/ +*.log +staticfiles/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..939f1e8 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# Local development environment file (copy to .env). +# For production server values, use .env.prod.example instead. +DJANGO_ENV=dev +DJANGO_DEBUG=true +DJANGO_SECRET_KEY=change-me-for-local-development +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 + +# Database (docker-compose sets DATABASE_URL for the web service) +DATABASE_URL=postgres://company_site:company_site@db:5432/company_site +# DB_HOST=db +# DB_PORT=5432 +# DB_NAME=company_site +# DB_USER=company_site +# DB_PASSWORD=company_site + +# Logging +# DJANGO_LOG_LEVEL=DEBUG + +# Analytics +TIANJI_ENABLED=false +TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js +TIANJI_WEBSITE_ID= + +# reCAPTCHA +RECAPTCHA_PUBLIC_KEY= +RECAPTCHA_PRIVATE_KEY= + +# Email (SMTP2GO) +EMAIL_HOST=mail.smtp2go.com +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_PORT=2525 +EMAIL_USE_TLS=true + +# Gunicorn +GUNICORN_WORKERS=2 +GUNICORN_BIND=0.0.0.0:8000 diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..9f7e4f5 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,41 @@ +# Server-side production environment file. +# Copy to /home/westfarn/Documents/django_live_sites/Company_Site/.env +# This file is NOT deployed from git — rsync excludes .env so server values persist. + +# Environment level: dev | beta | prod +DJANGO_ENV=prod +DJANGO_DEBUG=false +DJANGO_SECRET_KEY=replace-with-a-long-random-secret +DJANGO_ALLOWED_HOSTS=aimloperations.com,www.aimloperations.com + +# Logging (optional override; defaults: dev=DEBUG, beta=INFO, prod=WARNING) +# DJANGO_LOG_LEVEL=WARNING + +# PostgreSQL — DATABASE_URL is used by Django; DB_* are used by the db container. +DATABASE_URL=postgres://company_site:replace-db-password@db:5432/company_site +DB_NAME=company_site +DB_USER=company_site +DB_PASSWORD=replace-db-password + +# Analytics +TIANJI_ENABLED=true +TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js +TIANJI_WEBSITE_ID=replace-with-production-website-id + +# reCAPTCHA +RECAPTCHA_PUBLIC_KEY=replace-with-production-public-key +RECAPTCHA_PRIVATE_KEY=replace-with-production-private-key + +# Email (SMTP2GO) +EMAIL_HOST=mail.smtp2go.com +EMAIL_HOST_USER=replace-with-smtp-user +EMAIL_HOST_PASSWORD=replace-with-smtp-password +EMAIL_PORT=2525 +EMAIL_USE_TLS=true + +# Gunicorn +GUNICORN_WORKERS=2 +GUNICORN_BIND=0.0.0.0:8000 + +# Host port exposed by docker-compose.prod.yml +WEB_PORT=8000 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..860e4eb --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + pull_request: + branches: [master] + +jobs: + test: + runs-on: self-hosted + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Install dependencies + run: uv sync --frozen + + - name: Run unit tests + env: + DJANGO_ENV: dev + DJANGO_SECRET_KEY: test-secret-key + run: | + cd company_site + uv run python manage.py test diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 1cfa003..8b84f30 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,5 +1,6 @@ name: Deploy Company Site +# Deploy pipeline runs only on pushes to master (never on pull requests). on: workflow_run: workflows: [Unit Tests] @@ -7,9 +8,52 @@ on: branches: [master] jobs: + test: + runs-on: self-hosted + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Install dependencies + run: uv sync --frozen + + - name: Run unit tests + env: + DJANGO_ENV: dev + DJANGO_SECRET_KEY: test-secret-key + run: | + cd company_site + uv run python manage.py test + + docker: + runs-on: self-hosted + needs: test + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build Docker image + run: docker compose build + + - name: Run containerized tests + run: | + docker compose up -d db + docker compose run --rm --entrypoint "" \ + -e DJANGO_ENV=dev \ + -e DJANGO_SECRET_KEY=test-secret-key \ + -e DATABASE_URL=postgres://company_site:company_site@db:5432/company_site \ + web uv run python manage.py test + docker compose down + deploy: if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push' runs-on: self-hosted + needs: [test, docker] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b8081b6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PROJECT_ENVIRONMENT=/app/.venv + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libpq5 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev + +COPY company_site ./company_site +COPY scripts/docker-entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +WORKDIR /app/company_site + +EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index 94289e7..6a736f2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,58 @@ # company_site -Django site for compan -Somethinf \ No newline at end of file +Django site for AIML Operations. + +## Local development (uv) + +```bash +uv sync +cp .env.example .env +cd company_site +DJANGO_ENV=dev uv run python manage.py migrate +DJANGO_ENV=dev uv run python manage.py runserver +``` + +## Docker (dev + Postgres) + +```bash +cp .env.example .env +docker compose up --build +``` + +App: http://localhost:8000 + +## Environments + +Set `DJANGO_ENV` to one of: + +| Value | DEBUG default | Logging level | +|-------|---------------|---------------| +| `dev` | true | DEBUG | +| `beta` | false | INFO | +| `prod` | false | WARNING | + +Secrets and service config come from environment variables. See `.env.example`. + +## CI / deploy workflows + +| Workflow | Trigger | What runs | +|----------|---------|-----------| +| `.gitea/workflows/ci.yml` | Pull requests to `master` | Unit tests only | +| `.gitea/workflows/deploy.yml` | Push to `master` | Unit tests → Docker build/test → deploy | + +Deploy never runs on pull requests. Uses separate workflow files (not job `if` conditions) so Gitea runners handle it reliably. + +## Production deploy + +Server keeps its own `.env` at the live site path. Deploy rsyncs code but **never overwrites `.env`**. + +1. On the server, copy `.env.prod.example` to `.env` and fill in production values. +2. Run `bash scripts/validate-env.sh /path/to/.env` to verify required variables. +3. Push to `master` — the deploy workflow runs `scripts/deploy.sh`, which: + - rsyncs checkout to live site (preserving `.env`) + - validates environment variables + - `docker compose -f docker-compose.prod.yml build` + - `docker compose up -d` + - runs migrations in the web container + +Legacy venv/systemd deploy: `DEPLOY_MODE=legacy bash scripts/deploy.sh `. diff --git a/company_site/company_site/settings.py b/company_site/company_site/settings.py deleted file mode 100644 index ef203bd..0000000 --- a/company_site/company_site/settings.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -Django settings for company_site project. - -Generated by 'django-admin startproject' using Django 5.0. - -For more information on this file, see -https://docs.djangoproject.com/en/5.0/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/5.0/ref/settings/ -""" - -from pathlib import Path -import os - -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-0$+hho_6%-(ud^t%0zos(q&i@2&)9m+u&dgj77&51g$m#hr^0s' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True -TIANJI_ENABLED = not DEBUG -WEBMCP_ENABLED = False - -ALLOWED_HOSTS = ["*"] - - -# Application definition - -INSTALLED_APPS = [ - 'public.apps.PublicConfig', - 'financial.apps.FinancialConfig', - 'planning.apps.PlanningConfig', - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'phonenumber_field', - 'django_recaptcha', - -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'company_site.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - 'public.context_processors.tianji_tracking', - 'public.context_processors.webmcp_context', - 'public.context_processors.financial_access', - ], - }, - }, -] - -WSGI_APPLICATION = 'company_site.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/5.0/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', - } -} - - -# Password validation -# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/5.0/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.0/howto/static-files/ - -STATIC_URL = 'static/' - -# Default primary key field type -# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -# # email settings -# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -# EMAIL_USE_TLS = True -# EMAIL_HOST = 'smtp.outlook.office365.com'#'smtp-mail.outlook.com' -# EMAIL_HOST_USERNAME = 'ryan@aimloperations.com' -# EMAIL_HOST_USER = 'ryan@aimloperations.com' -# EMAIL_HOST_PASSWORD = '!HopeThisW0rkz' -# EMAIL_PORT = 587 -# SERVER_EMAIL = EMAIL_HOST_USER - -# Recapcha Stuff -RECAPTCHA_PUBLIC_KEY = '6LdXRbopAAAAAL9NT7C2J3Fuu_b6rvhhsPyxTd9Z' -RECAPTCHA_PRIVATE_KEY = '6LdXRbopAAAAAPt31zdQJaOwLseognmZHZEHmWlt' - -# SMTP2GO -EMAIL_HOST = 'mail.smtp2go.com' -EMAIL_HOST_USER = 'info.aimloperations.com' -EMAIL_HOST_PASSWORD = 'ZDErIII2sipNNVMz' -EMAIL_PORT = 2525 -EMAIL_USE_TLS = True - -# Authentication Redirects -LOGIN_REDIRECT_URL = '/' -LOGOUT_REDIRECT_URL = '/' - -# Tianji analytics (loaded on page load; users acknowledge via notice banner) -TIANJI_TRACKER_URL = 'https://tianji.aimloperations.com/tracker.js' -TIANJI_WEBSITE_ID = 'cm7w80pyy020oddswy2evl957' diff --git a/company_site/company_site/settings/__init__.py b/company_site/company_site/settings/__init__.py new file mode 100644 index 0000000..49b07b9 --- /dev/null +++ b/company_site/company_site/settings/__init__.py @@ -0,0 +1,12 @@ +"""Load environment-specific Django settings based on DJANGO_ENV.""" + +import os + +_environment = os.environ.get("DJANGO_ENV", "dev").lower() + +if _environment == "prod": + from .prod import * # noqa: F403 +elif _environment == "beta": + from .beta import * # noqa: F403 +else: + from .dev import * # noqa: F403 diff --git a/company_site/company_site/settings/base.py b/company_site/company_site/settings/base.py new file mode 100644 index 0000000..011475c --- /dev/null +++ b/company_site/company_site/settings/base.py @@ -0,0 +1,172 @@ +"""Shared Django settings for all environments.""" + +import os +from pathlib import Path +from urllib.parse import urlparse + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + + +def env(key: str, default: str | None = None) -> str | None: + return os.environ.get(key, default) + + +def env_bool(key: str, default: bool = False) -> bool: + value = os.environ.get(key) + if value is None: + return default + return value.lower() in {"1", "true", "yes", "on"} + + +def env_list(key: str, default: str = "") -> list[str]: + value = os.environ.get(key, default) + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def database_config() -> dict: + database_url = env("DATABASE_URL") + if database_url: + parsed = urlparse(database_url) + return { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": parsed.path.lstrip("/"), + "USER": parsed.username or "", + "PASSWORD": parsed.password or "", + "HOST": parsed.hostname or "", + "PORT": str(parsed.port or 5432), + } + } + + if env("DB_HOST"): + return { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": env("DB_NAME", "company_site"), + "USER": env("DB_USER", "company_site"), + "PASSWORD": env("DB_PASSWORD", ""), + "HOST": env("DB_HOST"), + "PORT": env("DB_PORT", "5432"), + } + } + + return { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } + } + + +SECRET_KEY = env( + "DJANGO_SECRET_KEY", + "django-insecure-dev-only-change-me-before-production", +) + +DEBUG = env_bool("DJANGO_DEBUG", False) +TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG) +WEBMCP_ENABLED = env_bool("WEBMCP_ENABLED", False) + +allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*") +ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"] + +INSTALLED_APPS = [ + "public.apps.PublicConfig", + "financial.apps.FinancialConfig", + "planning.apps.PlanningConfig", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "whitenoise.runserver_nostatic", + "django.contrib.staticfiles", + "phonenumber_field", + "django_recaptcha", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "company_site.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "public.context_processors.tianji_tracking", + "public.context_processors.webmcp_context", + "public.context_processors.financial_access", + ], + }, + }, +] + +WSGI_APPLICATION = "company_site.wsgi.application" + +DATABASES = database_config() + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +RECAPTCHA_PUBLIC_KEY = env("RECAPTCHA_PUBLIC_KEY", "") +RECAPTCHA_PRIVATE_KEY = env("RECAPTCHA_PRIVATE_KEY", "") + +EMAIL_HOST = env("EMAIL_HOST", "mail.smtp2go.com") +EMAIL_HOST_USER = env("EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "") +EMAIL_PORT = int(env("EMAIL_PORT", "2525")) +EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True) + +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/" + +TIANJI_TRACKER_URL = env( + "TIANJI_TRACKER_URL", + "https://tianji.aimloperations.com/tracker.js", +) +TIANJI_WEBSITE_ID = env("TIANJI_WEBSITE_ID", "") diff --git a/company_site/company_site/settings/beta.py b/company_site/company_site/settings/beta.py new file mode 100644 index 0000000..2d11e94 --- /dev/null +++ b/company_site/company_site/settings/beta.py @@ -0,0 +1,14 @@ +"""Beta/staging settings.""" + +from .base import * # noqa: F403 +from .logging import build_logging_config, logging_level_for_env + +DEBUG = env_bool("DJANGO_DEBUG", False) # noqa: F405 +TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405 + +if DEBUG: + import warnings + + warnings.warn("DEBUG is enabled in beta environment.", stacklevel=1) + +LOGGING = build_logging_config(logging_level_for_env("beta"), "beta") diff --git a/company_site/company_site/settings/dev.py b/company_site/company_site/settings/dev.py new file mode 100644 index 0000000..96c5c38 --- /dev/null +++ b/company_site/company_site/settings/dev.py @@ -0,0 +1,15 @@ +"""Development settings.""" + +from .base import * # noqa: F403 +from .logging import build_logging_config, logging_level_for_env + +DEBUG = True +TIANJI_ENABLED = env_bool("TIANJI_ENABLED", False) # noqa: F405 + +STORAGES = { + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, +} + +LOGGING = build_logging_config(logging_level_for_env("dev"), "dev") diff --git a/company_site/company_site/settings/logging.py b/company_site/company_site/settings/logging.py new file mode 100644 index 0000000..55f6df8 --- /dev/null +++ b/company_site/company_site/settings/logging.py @@ -0,0 +1,58 @@ +"""Environment-specific logging configuration.""" + +import os + + +def build_logging_config(level: str, environment: str) -> dict: + """Return a Django LOGGING dict for the given level and environment name.""" + return { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": ( + f"{{levelname}} {{asctime}} {{name}} {{process:d}} {{thread:d}} " + f"[env={environment}] {{message}}" + ), + "style": "{", + }, + "simple": { + "format": f"{{levelname}} [env={environment}] {{message}}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose" if environment == "dev" else "simple", + }, + }, + "root": { + "handlers": ["console"], + "level": level, + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": level, + "propagate": False, + }, + "django.request": { + "handlers": ["console"], + "level": "ERROR" if environment == "prod" else level, + "propagate": False, + }, + }, + } + + +def logging_level_for_env(environment: str) -> str: + override = os.environ.get("DJANGO_LOG_LEVEL") + if override: + return override.upper() + + if environment == "dev": + return "DEBUG" + if environment == "beta": + return "INFO" + return "WARNING" diff --git a/company_site/company_site/settings/prod.py b/company_site/company_site/settings/prod.py new file mode 100644 index 0000000..500943e --- /dev/null +++ b/company_site/company_site/settings/prod.py @@ -0,0 +1,12 @@ +"""Production settings.""" + +from .base import * # noqa: F403 +from .logging import build_logging_config, logging_level_for_env + +DEBUG = False +TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405 + +if not env("DJANGO_SECRET_KEY"): # noqa: F405 + raise ValueError("DJANGO_SECRET_KEY must be set in production.") + +LOGGING = build_logging_config(logging_level_for_env("prod"), "prod") diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d93c04c --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,30 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + web: + build: . + restart: unless-stopped + ports: + - "${WEB_PORT:-8000}:8000" + env_file: + - .env + depends_on: + db: + condition: service_healthy + +volumes: + postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8c09c1b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: company_site + POSTGRES_USER: company_site + POSTGRES_PASSWORD: company_site + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U company_site -d company_site"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + + web: + build: . + ports: + - "8000:8000" + env_file: + - .env + environment: + DJANGO_ENV: dev + DATABASE_URL: postgres://company_site:company_site@db:5432/company_site + depends_on: + db: + condition: service_healthy + +volumes: + postgres_data: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fe98e7a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "company-site" +version = "0.1.0" +description = "Django site for AIML Operations" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "django==5.0", + "django-enum==2.1.0", + "django-phonenumber-field==8.0.0", + "django-recaptcha==4.0.0", + "gunicorn==23.0.0", + "phonenumbers==9.0.0", + "psycopg2-binary==2.9.10", + "python-dateutil==2.9.0.post0", + "typing-extensions==4.8.0", + "whitenoise==6.9.0", +] + +[dependency-groups] +dev = [ + "pre-commit==4.1.0", +] + +[tool.uv] +package = false diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 367b4ad..0000000 --- a/requirements.txt +++ /dev/null @@ -1,20 +0,0 @@ -asgiref==3.7.2 -cfgv==3.4.0 -distlib==0.3.9 -Django==5.0 -django-enum==2.1.0 -django-phonenumber-field==8.0.0 -django-recaptcha==4.0.0 -python-dateutil==2.9.0.post0 -gunicorn==23.0.0 -psycopg2-binary==2.9.10 -filelock==3.17.0 -identify==2.6.9 -nodeenv==1.9.1 -phonenumbers==9.0.0 -platformdirs==4.3.6 -pre_commit==4.1.0 -PyYAML==6.0.2 -sqlparse==0.4.4 -typing_extensions==4.8.0 -virtualenv==20.29.3 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index bcf855b..1328499 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -2,10 +2,9 @@ set -euo pipefail REPO_ROOT="/home/westfarn/Documents/django_live_sites/Company_Site" -APP_DIR="$REPO_ROOT/company_site" -VENV="$REPO_ROOT/venv" -SETTINGS="$APP_DIR/company_site/settings.py" -LOCAL_SETTINGS="$APP_DIR/company_site/local_settings.py" +ENV_FILE="$REPO_ROOT/.env" +COMPOSE_FILE="$REPO_ROOT/docker-compose.prod.yml" +VALIDATE_SCRIPT="$REPO_ROOT/scripts/validate-env.sh" if [[ $# -lt 1 || -z "${1:-}" ]]; then echo "Usage: $0 " >&2 @@ -13,24 +12,67 @@ if [[ $# -lt 1 || -z "${1:-}" ]]; then fi CHECKOUT="$1" +DEPLOY_MODE="${DEPLOY_MODE:-docker}" -cp "$SETTINGS" /tmp/company_site_settings.py.bak +if [[ "$DEPLOY_MODE" == "docker" ]]; then + echo "Syncing application files to $REPO_ROOT (preserving server .env)..." + rsync -a --delete \ + --exclude .git/ \ + --exclude .venv/ \ + --exclude .env \ + --exclude db.sqlite3 \ + --exclude staticfiles/ \ + "$CHECKOUT/" "$REPO_ROOT/" + + if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE on the server." >&2 + echo "Create it from .env.prod.example and configure production values." >&2 + exit 1 + fi + + bash "$VALIDATE_SCRIPT" "$ENV_FILE" + + cd "$REPO_ROOT" + + echo "Building Docker images..." + docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" build + + echo "Starting containers..." + docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --remove-orphans + + echo "Running database migrations..." + docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T web \ + uv run python manage.py migrate --noinput + + echo "Deploy complete." + docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" ps + exit 0 +fi + +# Legacy venv/systemd deploy (DEPLOY_MODE=legacy) +APP_DIR="$REPO_ROOT/company_site" +VENV="$REPO_ROOT/.venv" +SETTINGS_DIR="$APP_DIR/company_site/settings" +LOCAL_SETTINGS="$APP_DIR/company_site/local_settings.py" + +cp -r "$SETTINGS_DIR" /tmp/company_site_settings.bak cp "$LOCAL_SETTINGS" /tmp/company_site_local_settings.py.bak 2>/dev/null || true rsync -a --delete \ - --exclude venv/ \ + --exclude .venv/ \ --exclude .git/ \ - --exclude 'company_site/company_site/settings.py' \ + --exclude .env \ + --exclude 'company_site/company_site/settings/' \ --exclude 'company_site/company_site/local_settings.py' \ "$CHECKOUT/" "$REPO_ROOT/" -cp /tmp/company_site_settings.py.bak "$SETTINGS" +rm -rf "$SETTINGS_DIR" +cp -r /tmp/company_site_settings.bak "$SETTINGS_DIR" cp /tmp/company_site_local_settings.py.bak "$LOCAL_SETTINGS" 2>/dev/null || true -source "$VENV/bin/activate" -pip install -r "$REPO_ROOT/requirements.txt" +uv sync --frozen --directory "$REPO_ROOT" cd "$APP_DIR" -python manage.py migrate --noinput -python manage.py collectstatic --noinput +uv run python manage.py migrate --noinput +uv run python manage.py collectstatic --noinput sudo systemctl restart company diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100755 index 0000000..801c61b --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /app/company_site + +wait_for_database() { + if [[ -z "${DATABASE_URL:-}" && -z "${DB_HOST:-}" ]]; then + return 0 + fi + + echo "Waiting for database..." + for _ in $(seq 1 30); do + if uv run python - <<'PY' +import os +import sys + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "company_site.settings") + +import django +from django.db import connections +from django.db.utils import OperationalError + +django.setup() + +try: + connections["default"].ensure_connection() +except OperationalError: + sys.exit(1) +PY + then + echo "Database is ready." + return 0 + fi + sleep 2 + done + + echo "Database did not become ready in time." >&2 + exit 1 +} + +wait_for_database + +uv run python manage.py migrate --noinput +uv run python manage.py collectstatic --noinput + +exec uv run gunicorn company_site.wsgi:application \ + --bind "${GUNICORN_BIND:-0.0.0.0:8000}" \ + --workers "${GUNICORN_WORKERS:-2}" diff --git a/scripts/validate-env.sh b/scripts/validate-env.sh new file mode 100755 index 0000000..219ffe9 --- /dev/null +++ b/scripts/validate-env.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:?Usage: $0 }" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Environment file not found: $ENV_FILE" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +DJANGO_ENV="${DJANGO_ENV:-prod}" + +required_vars=( + DJANGO_SECRET_KEY + DJANGO_ALLOWED_HOSTS + DB_PASSWORD + DB_NAME + DB_USER + DATABASE_URL +) + +if [[ "$DJANGO_ENV" == "prod" || "$DJANGO_ENV" == "beta" ]]; then + required_vars+=( + RECAPTCHA_PUBLIC_KEY + RECAPTCHA_PRIVATE_KEY + EMAIL_HOST_USER + EMAIL_HOST_PASSWORD + ) +fi + +if [[ "$DJANGO_ENV" == "prod" ]]; then + required_vars+=(TIANJI_WEBSITE_ID) +fi + +missing=() +for var in "${required_vars[@]}"; do + if [[ -z "${!var:-}" ]]; then + missing+=("$var") + fi +done + +if ((${#missing[@]} > 0)); then + echo "Missing required environment variables in $ENV_FILE:" >&2 + printf ' - %s\n' "${missing[@]}" >&2 + echo "Copy .env.prod.example to .env on the server and set production values." >&2 + exit 1 +fi + +if [[ "$DJANGO_ENV" == "prod" && "$DJANGO_SECRET_KEY" == change-me* ]]; then + echo "DJANGO_SECRET_KEY must be changed from the example value for production." >&2 + exit 1 +fi + +echo "Environment validation passed (DJANGO_ENV=$DJANGO_ENV)." diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..938227e --- /dev/null +++ b/uv.lock @@ -0,0 +1,363 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "company-site" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "django" }, + { name = "django-enum" }, + { name = "django-phonenumber-field" }, + { name = "django-recaptcha" }, + { name = "gunicorn" }, + { name = "phonenumbers" }, + { name = "psycopg2-binary" }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, + { name = "whitenoise" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pre-commit" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = "==5.0" }, + { name = "django-enum", specifier = "==2.1.0" }, + { name = "django-phonenumber-field", specifier = "==8.0.0" }, + { name = "django-recaptcha", specifier = "==4.0.0" }, + { name = "gunicorn", specifier = "==23.0.0" }, + { name = "phonenumbers", specifier = "==9.0.0" }, + { name = "psycopg2-binary", specifier = "==2.9.10" }, + { name = "python-dateutil", specifier = "==2.9.0.post0" }, + { name = "typing-extensions", specifier = "==4.8.0" }, + { name = "whitenoise", specifier = "==6.9.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pre-commit", specifier = "==4.1.0" }] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "django" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/a6/46e250737d46e955e048f6bbc2948fb22f0de3f3ab828d3803070dc1260e/Django-5.0.tar.gz", hash = "sha256:7d29e14dfbc19cb6a95a4bd669edbde11f5d4c6a71fdaa42c2d40b6846e807f7", size = 10585390, upload-time = "2023-12-04T13:12:50.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/c7/61b02c0ef9e129080a8c2bffefb3cb2b9ddddece4c44dc473c1c4f0647c1/Django-5.0-py3-none-any.whl", hash = "sha256:3a9fd52b8dbeae335ddf4a9dfa6c6a0853a1122f1fb071a8d5eca979f73a05c8", size = 8136382, upload-time = "2023-12-04T13:12:41.502Z" }, +] + +[[package]] +name = "django-enum" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/75/082f9524e644ceee38162151938db09b41fa1e3e1c57645c4632c567b446/django_enum-2.1.0.tar.gz", hash = "sha256:e3b478608f1bfedde9a3da215e00419ddecd939e99a07b32ea17a310aff509c0", size = 506766, upload-time = "2025-02-25T02:28:28.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/23/960bbafd0ab867b40e162b608d3446c49c4c6324ee2d7f42a27e5f05a292/django_enum-2.1.0-py3-none-any.whl", hash = "sha256:a71f6417598e1e5dd7385a6a9b9e5b6e15dfdb8fc37fe40d09b7f928f4b72312", size = 29159, upload-time = "2025-02-25T02:28:26.164Z" }, +] + +[[package]] +name = "django-phonenumber-field" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/d1/0a2ba41434d98ac9a2669ea7edfc8d1bc75055da450d94894c597913e01c/django_phonenumber_field-8.0.0.tar.gz", hash = "sha256:8a560fe1b01b94c9de8cde22bc373b695f023cc6df4baba00264cb079da9f631", size = 43759, upload-time = "2024-06-24T13:49:13.649Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/ab/4738a40d6f297688ecedd08cf6dc1608ad06c08ba3c03d1fc85b19ac6717/django_phonenumber_field-8.0.0-py3-none-any.whl", hash = "sha256:196c917b70c01a98e327f482eb8a4a4a55a29891db551f99078585397370b3ba", size = 66441, upload-time = "2024-06-24T13:49:11.65Z" }, +] + +[[package]] +name = "django-recaptcha" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/6b/6edf89da076b2d1ea042e14f116de80be18d25b17af158038d5fc14c00bb/django-recaptcha-4.0.0.tar.gz", hash = "sha256:5316438f97700c431d65351470d1255047e3f2cd9af0f2f13592b637dad9213e", size = 22907, upload-time = "2023-11-16T15:29:08.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/d7/09cefb2b4a7dc9ed8a6aabb176ea86eb904a8f73671358436e4b0aa81b93/django_recaptcha-4.0.0-py3-none-any.whl", hash = "sha256:0d912d5c7c009df4e47accd25029133d47a74342dbd2a8edc2877b6bffa971a3", size = 21915, upload-time = "2023-11-16T15:29:06.79Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "phonenumbers" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/96/58ab3aa4f8695c85f5dce60c15bb3b113856f420d4f0575f6b6e92c1acb5/phonenumbers-9.0.0.tar.gz", hash = "sha256:094a6f728e3c2b1906df4494a480743a3c797320f721f2b53f1400fd4d8ed5f5", size = 2296775, upload-time = "2025-03-04T12:38:43.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/82/943ce12a9db8932be3a1baff7cbc93524262942f2c41678242f410c8f420/phonenumbers-9.0.0-py2.py3-none-any.whl", hash = "sha256:f566eddf6219d9af9b4aad454ba411a1df565d13b875a490fd33d1d202c1148d", size = 2582416, upload-time = "2025-03-04T12:38:41.974Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/13/b62d075317d8686071eb843f0bb1f195eb332f48869d3c31a4c6f1e063ac/pre_commit-4.1.0.tar.gz", hash = "sha256:ae3f018575a588e30dfddfab9a05448bfbd6b73d78709617b5a2b853549716d4", size = 193330, upload-time = "2025-01-20T18:31:48.681Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/b3/df14c580d82b9627d173ceea305ba898dca135feb360b6d84019d0803d3b/pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b", size = 220560, upload-time = "2025-01-20T18:31:47.319Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" }, + { url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" }, + { url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" }, + { url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" }, + { url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" }, + { url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, + { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, + { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/7a/8b94bb016069caa12fc9f587b28080ac33b4fbb8ca369b98bc0a4828543e/typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef", size = 71456, upload-time = "2023-09-18T04:01:56.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/21/7d397a4b7934ff4028987914ac1044d3b7d52712f30e2ac7a2ae5bc86dd0/typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0", size = 31584, upload-time = "2023-09-18T04:01:55.398Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, +] + +[[package]] +name = "whitenoise" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/cf/c15c2f21aee6b22a9f6fc9be3f7e477e2442ec22848273db7f4eb73d6162/whitenoise-6.9.0.tar.gz", hash = "sha256:8c4a7c9d384694990c26f3047e118c691557481d624f069b7f7752a2f735d609", size = 25920, upload-time = "2025-02-06T22:16:34.957Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b2/2ce9263149fbde9701d352bda24ea1362c154e196d2fda2201f18fc585d7/whitenoise-6.9.0-py3-none-any.whl", hash = "sha256:c8a489049b7ee9889617bb4c274a153f3d979e8f51d2efd0f5b403caf41c57df", size = 20161, upload-time = "2025-02-06T22:16:32.589Z" }, +]