Author SHA1 Message Date
westfarn 00686ba53f Update workflow to use server-infra 2026-07-08 06:02:17 -05:00
westfarnandCursor d2f2409a53 fix(static): don't 500 on missing manifest entries at runtime
CI / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 9s
Templates reference public/img/logo.png (favicon, brand logo, social
share images) which isn't present in the repo. With the strict manifest
storage this raised ValueError at request time -> HTTP 500. Override
stored_name (and set manifest_strict=False) to fall back to the plain
name so a missing static degrades to a broken asset instead of a 500,
matching the previous non-manifest behaviour.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 13:18:49 -05:00
westfarnandCursor 5d378b7b19 fix(settings): accept JSON-array env lists in env_list
CI / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 9s
The production .env stores DJANGO_ALLOWED_HOSTS as a JSON array (legacy
format), but env_list only split on commas, yielding broken entries like
'["aimloperations.com"' and causing DisallowedHost (HTTP 400) for every
request. Parse JSON arrays as well as comma-separated values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 13:13:57 -05:00
westfarnandCursor 5899c1f14f fix(static): tolerate missing asset references in collectstatic
CI / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 10s
WhiteNoise's manifest storage strictly resolves every referenced file
during collectstatic, including sourceMappingURL comments in vendored
JS bundles. A missing .map (financial/js/.../dashboard-free.js.map)
broke the prod container at startup. Add TolerantManifestStaticFilesStorage
which leaves unresolved references untouched instead of raising.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 13:10:51 -05:00
westfarn a4b37a0bb0 g
CI / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 10s
2026-07-07 11:20:15 -05:00
westfarn 115c5ae319 Dockerize Django app with dev/beta/prod env config and uv.
CI / test (pull_request) Successful in 11s
Unit Tests / test (pull_request) Failing after 6s
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.
2026-07-07 05:58:44 -05:00
westfarn 7dd5ec3be1 Add Employee vs Client user type and filter time/reports by employee (#14) (#15)
Unit Tests / test (push) Successful in 16s
## Summary

- Adds `UserProfile` model with mutually exclusive **Employee** / **Client** types
- Replaces auto-Employee signal with auto-Client profile on user creation
- Data migration: users with time log entries → Employee; others → Client (orphan Employee rows removed)
- Admin UI at `/financial/manage_users` to set any user's type; profile page shows current type
- **Employees** can log time; **Clients** get read-only access to reports and time logs
- Time logs, reports, and dashboard filter to employees only
- 14 new tests covering signals, type switching, access control, and filtering

## Design decisions (from issue Q&A)

1. Client login = read-only financial access (reports + time logs, no edit/log time)
2. Employee and Client are strictly mutually exclusive
3. Admins (superusers) can change type via Manage Users
4. Bulk migration applied for existing users

## Test plan

- [x] `python manage.py test financial.tests` (14 tests pass)
- [x] `python manage.py test public.tests` (21 tests pass)
- [ ] Run migration on staging: `python manage.py migrate`
- [ ] Verify admin can set user types at `/financial/manage_users`
- [ ] Verify employee can log time at `/financial/timekeeping`
- [ ] Verify client sees reports/time logs read-only, cannot log time
- [ ] Verify employee filter dropdown excludes clients

Closes #14

Reviewed-on: #15
2026-07-05 12:54:50 +00:00
westfarn 2fb5204614 Fix WebMCP schema validity on contact form (#13)
Deploy Company Site / test (push) Successful in 9s
Deploy Company Site / deploy (push) Successful in 4s
## Summary

Follow-up to PR #12 / Lighthouse agentic-browsing audit on `/contact` (score 0.88).

- Add `toolparamdescription` to all contact form fields for WebMCP schema validity
- Move reCAPTCHA outside the annotated `<form>` so `g-recaptcha-response` is excluded from the declarative tool schema
- Always render `toolname` / `tooldescription` on the contact form (not gated by `WEBMCP_ENABLED`)
- Override `django_recaptcha/widget_v3.html` to associate captcha with `form="contact-form"`
- Update docs with HTTPS Lighthouse commands

## Test plan

- [x] `python manage.py test public.tests` — 21 tests pass
- [ ] Deploy and re-run Lighthouse agentic-browsing on `https://aimloperations.com/contact`
- [ ] Confirm `webmcp-schema-validity` passes (expected overall score 1.0)

Reviewed-on: #13
2026-07-02 18:02:44 +00:00
westfarn bb5aa6de82 Add WebMCP tool registration for agentic browsing (#9) (#12)
Deploy Company Site / test (push) Successful in 10s
Deploy Company Site / deploy (push) Successful in 4s
## Summary

- Add `WEBMCP_ENABLED` setting and guarded `webmcp-tools.js` loader on public pages
- Register navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`) and contact tool (`submit_contact_inquiry`)
- Annotate contact form with declarative WebMCP attributes for Lighthouse form coverage
- Extend `PUBLIC_PAGE_ENTRIES` with summaries shared by SEO and WebMCP tools
- Add `docs/webmcp.md`, update `docs/agentic-browsing.md`, and regression tests

Closes #9

## Test plan

- [x] `python manage.py test public.tests` — 20 tests pass
- [ ] Set `WEBMCP_ENABLED=True`, enable Chrome experimental web platform features flag
- [ ] Verify homepage HTML includes `webmcp-config` and `webmcp-tools.js`
- [ ] Verify `/contact` form has `toolname="submit_contact_inquiry"`
- [ ] Run Lighthouse agentic-browsing on `/` and `/contact` with experimental flag
- [ ] Call `list_services` from WebMCP-capable Chrome on homepage
- [ ] Call `submit_contact_inquiry` on `/contact` with `DEBUG=True` (reCAPTCHA bypass)

Reviewed-on: #12
2026-07-02 16:57:47 +00:00
48 changed files with 2556 additions and 287 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.venv
**/__pycache__
*.py[cod]
db.sqlite3
.env
htmlcov/
.pytest_cache/
.mypy_cache/
*.log
staticfiles/
+37
View File
@@ -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
+41
View File
@@ -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
+28
View File
@@ -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
+42 -10
View File
@@ -1,7 +1,10 @@
name: Deploy Company Site
# Deploy pipeline runs only on pushes to master (never on pull requests).
on:
push:
workflow_run:
workflows: [Unit Tests]
types: [completed]
branches: [master]
jobs:
@@ -11,23 +14,52 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python environment
- name: Install uv
run: |
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
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
../.venv/bin/python manage.py test
uv run python manage.py test
deploy:
needs: test
docker:
runs-on: self-hosted
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to live site
run: bash scripts/deploy.sh "${{ gitea.workspace }}"
- 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]
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
steps:
- name: Deploy company_site prod
run: |
"${SERVER_INFRA_ROOT}/scripts/deploy.sh" \
--app company_site \
--env prod \
--ref "${{ gitea.event.workflow_run.head_sha }}"
+30
View File
@@ -0,0 +1,30 @@
name: Unit Tests
on:
push:
branches: [master]
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
+28
View File
@@ -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"]
+56 -2
View File
@@ -1,4 +1,58 @@
# company_site
Django site for compan
Somethinf
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 <checkout>`.
-161
View File
@@ -1,161 +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
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',
],
},
},
]
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'
@@ -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
+182
View File
@@ -0,0 +1,182 @@
"""Shared Django settings for all environments."""
import json
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 []
value = value.strip()
# Accept a JSON array (e.g. '["a","b"]') as well as a comma-separated list.
if value.startswith("["):
try:
parsed = json.loads(value)
except ValueError:
parsed = None
if isinstance(parsed, list):
return [str(item).strip() for item in parsed if str(item).strip()]
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": "company_site.storage.TolerantManifestStaticFilesStorage",
},
}
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", "")
@@ -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")
+15
View File
@@ -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")
@@ -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"
@@ -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")
+32
View File
@@ -0,0 +1,32 @@
"""Custom static files storage.
WhiteNoise's manifest storage post-processes JS/CSS during ``collectstatic`` and
strictly resolves every referenced file, including ``sourceMappingURL`` comments
in vendored bundles. Some third-party assets reference ``.map`` files that are
not shipped, which makes ``collectstatic`` fail hard.
``TolerantManifestStaticFilesStorage`` leaves such unresolved references
untouched instead of raising, so a missing source map can't break the build.
"""
from whitenoise.storage import CompressedManifestStaticFilesStorage
class TolerantManifestStaticFilesStorage(CompressedManifestStaticFilesStorage):
# Don't 500 at runtime when a {% static %} reference isn't in the manifest;
# fall back to the plain name (mirrors non-manifest storage behaviour).
manifest_strict = False
def _stored_name(self, name, hashed_files):
"""Tolerate missing references during collectstatic post-processing."""
try:
return super()._stored_name(name, hashed_files)
except ValueError:
return name
def stored_name(self, name):
"""Tolerate missing manifest entries at request time."""
try:
return super().stored_name(name)
except ValueError:
return name
+14 -3
View File
@@ -25,8 +25,17 @@ Test at minimum:
| **sitemap.xml** | `GET /sitemap.xml` — public marketing URLs |
| **Accessibility tree** | Form labels, semantic nav controls, ARIA on dialogs |
| **Layout stability** | Fixed cookie banner, reserved hero/marquee space, font fallbacks |
| **WebMCP tools** | `navigator.modelContext` tools behind `WEBMCP_ENABLED` (see [webmcp.md](webmcp.md)) |
WebMCP is intentionally deferred until browser and agent support stabilizes.
## WebMCP (issue #9)
When `WEBMCP_ENABLED=True`:
- Navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`) load on all public pages
- Contact page registers `submit_contact_inquiry` via declarative form annotations (`toolname`, `tooldescription`, `toolparamdescription`); reCAPTCHA renders outside the annotated form
- Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment
Full tool catalog, Chrome flag setup, and reCAPTCHA notes: **[docs/webmcp.md](webmcp.md)**
## Regression checklist
@@ -36,10 +45,12 @@ Before merging public-facing template or CSS changes:
2. Nav dropdowns use `<button>` triggers with `aria-expanded` / `aria-haspopup`.
3. Cookie consent banner stays `position: fixed` (no document flow shift).
4. `GET /robots.txt`, `/sitemap.xml`, `/llms.txt` return 200.
5. Re-run Lighthouse agentic-browsing on homepage and contact page.
5. With `WEBMCP_ENABLED=True`, public pages include `webmcp-config` and `webmcp-tools.js`; contact form always has `toolname`, `tooldescription`, and `toolparamdescription` on fields.
6. Re-run Lighthouse agentic-browsing on homepage and contact page (Chrome experimental flag on).
## References
- [Lighthouse Agentic Browsing audit overview](https://locomotive.agency/blog/lighthouse-agentic-browsing-audit/)
- [llms.txt proposal](https://llmstxt.org/)
- Gitea issue #5
- [WebMCP tool catalog](webmcp.md)
- Gitea issue #5 (foundational), issue #9 (WebMCP)
+108
View File
@@ -0,0 +1,108 @@
# WebMCP Tool Catalog
WebMCP exposes public marketing actions as named, callable tools via `navigator.modelContext` (or `document.modelContext` in newer builds). Tools register only when `WEBMCP_ENABLED=True` in Django settings.
## Enablement
### 1. Django setting
```python
# company_site/settings.py (or environment-specific settings)
WEBMCP_ENABLED = True
```
Default is `False` so production stays opt-in until you deliberately enable agent tooling.
### 2. Chrome experimental flag
WebMCP requires the experimental web platform features flag:
1. Open `chrome://flags/#enable-experimental-web-platform-features`
2. Set **Enable experimental web platform features** to **Enabled**
3. Restart Chrome
Serve the site over HTTPS (or `localhost`) — WebMCP requires a secure context.
### 3. Lighthouse audit
```bash
npx lighthouse@latest https://aimloperations.com/contact \
--only-categories=agentic-browsing \
--chrome-flags="--enable-experimental-web-platform-features" \
--output=html --output-path=agentic-browsing-contact.html
npx lighthouse@latest https://aimloperations.com/ \
--only-categories=agentic-browsing \
--chrome-flags="--enable-experimental-web-platform-features" \
--output=html --output-path=agentic-browsing-home.html
```
Use `https://` URLs to avoid redirect warnings. Run with `WEBMCP_ENABLED=True` on the target environment.
## Registered tools
| Tool | Pages | readOnly | Description |
|------|-------|----------|-------------|
| `list_services` | All public pages | Yes | Returns service name, slug, URL, and summary |
| `get_page_content` | All public pages | Yes | Look up a page by slug or display name |
| `navigate_to_service` | All public pages | Yes | Resolve a service to its canonical URL |
| `open_contact_with_subject` | All public pages | Yes | Build a contact URL with `?subject=` pre-filled |
| `submit_contact_inquiry` | `/contact` only | No | POST a contact inquiry to the Django contact endpoint |
### `submit_contact_inquiry`
**Input schema:**
| Field | Type | Required |
|-------|------|----------|
| `name` | string | Yes |
| `email` | string | Yes |
| `subject` | string | Yes |
| `message` | string | No |
**Output:** JSON with `success: true` and a confirmation message, or `success: false` with an `error` string.
**reCAPTCHA limitation:** In production (`DEBUG=False`), the contact endpoint requires reCAPTCHA v3. Automated agents cannot complete captcha without a real browser session and the reCAPTCHA widget. For Lighthouse demos, run with `DEBUG=True` or use the declarative HTML form (agent fills fields; user submits manually).
### Declarative form annotation
The contact `<form>` declares WebMCP coverage via HTML attributes (always rendered, independent of `WEBMCP_ENABLED`):
```html
<form id="contact-form" toolname="submit_contact_inquiry"
tooldescription="Submit a contact inquiry to AI ML Operations">
<input name="name" toolparamdescription="Full name of the person submitting the inquiry." required>
...
</form>
<!-- reCAPTCHA renders outside the annotated form and links via form="contact-form" -->
```
The imperative `registerTool` on `/contact` adds structured `execute` behavior with success/error responses when `WEBMCP_ENABLED=True`.
## Data source
Service metadata is sourced from `PUBLIC_PAGE_ENTRIES` in `public/seo.py` — the same config that powers `sitemap.xml` and `llms.txt`. Navigation tools always stay in sync with SEO endpoints.
## Security scope
- Tools run in the visitor's browser session (no API keys)
- Only unauthenticated marketing flows are exposed
- Planning, financial, and admin actions are **not** registered
## Manual verification
Use [Chrome Labs Awesome WebMCP](https://github.com/chrome-labs/awesome-webmcp) or DevTools console:
```javascript
// Feature-detect (Chrome with experimental flag)
'modelContext' in navigator && typeof navigator.modelContext.registerTool === 'function'
```
With a WebMCP-capable browser on `/`, call `list_services` from the agent UI. On `/contact`, call `submit_contact_inquiry` with test data (DEBUG mode).
## References
- [WebMCP specification overview](https://specification.website/spec/agent-readiness/webmcp/)
- [Google Chrome modern-web-guidance — agentic JavaScript tools](https://github.com/GoogleChrome/modern-web-guidance/blob/main/skills/modern-web-guidance/guides/webmcp/agentic-javascript-tools.md)
- [Awesome WebMCP demos](https://github.com/chrome-labs/awesome-webmcp)
- Gitea issue #9
+6 -2
View File
@@ -1,6 +1,5 @@
from django.contrib import admin
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell
# Register your models here.
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell, UserProfile
class ContractAdmin(admin.ModelAdmin):
pass
@@ -8,6 +7,10 @@ class ContractAdmin(admin.ModelAdmin):
class EmployeeAdmin(admin.ModelAdmin):
pass
class UserProfileAdmin(admin.ModelAdmin):
list_display = ("user", "user_type")
list_filter = ("user_type",)
class ChargeNumberAdmin(admin.ModelAdmin):
pass
@@ -19,6 +22,7 @@ class TimeCardCellAdmin(admin.ModelAdmin):
admin.site.register(Contract, ContractAdmin)
admin.site.register(Employee, EmployeeAdmin)
admin.site.register(UserProfile, UserProfileAdmin)
admin.site.register(ChargeNumber, ChargeNumberAdmin)
admin.site.register(TimeCard, TimeCardAdmin)
admin.site.register(TimeCardCell, TimeCardCellAdmin)
+12 -1
View File
@@ -1,7 +1,8 @@
import datetime
from django import forms
from django.contrib.auth.models import User
from django.forms import ModelForm
from .models import Employee, Contract, ChargeNumber, TimeCardCell, AddressModel
from .models import Employee, Contract, ChargeNumber, TimeCardCell, AddressModel, UserProfile, set_user_type
class NewEmployeeForm(ModelForm):
first_name = forms.CharField(max_length=30, required=False, label="First Name")
@@ -37,6 +38,7 @@ class NewEmployeeForm(ModelForm):
employee.workAddress = address
if commit:
employee.save()
set_user_type(employee.user, UserProfile.UserType.EMPLOYEE)
return employee
class EmployeeForm(ModelForm):
@@ -44,6 +46,15 @@ class EmployeeForm(ModelForm):
model = Employee
fields = ["user", "manager", "primaryAddress", "workAddress", "phoneNumber", "slary"]
class UserProfileForm(ModelForm):
class Meta:
model = UserProfile
fields = ["user_type"]
class AdminUserTypeForm(forms.Form):
user = forms.ModelChoiceField(queryset=User.objects.order_by("username"))
user_type = forms.ChoiceField(choices=UserProfile.UserType.choices)
class ContractForm(ModelForm):
class Meta:
model = Contract
@@ -0,0 +1,79 @@
# Generated manually for issue #14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def migrate_user_profiles(apps, schema_editor):
User = apps.get_model("auth", "User")
UserProfile = apps.get_model("financial", "UserProfile")
Employee = apps.get_model("financial", "Employee")
TimeCardCell = apps.get_model("financial", "TimeCardCell")
employee_user_ids = set(
TimeCardCell.objects.values_list("timeCard__employee__user_id", flat=True)
)
for user in User.objects.all():
if user.id in employee_user_ids:
UserProfile.objects.update_or_create(
user_id=user.id,
defaults={"user_type": "employee"},
)
else:
UserProfile.objects.update_or_create(
user_id=user.id,
defaults={"user_type": "client"},
)
Employee.objects.filter(user_id=user.id).delete()
def reverse_migrate_user_profiles(apps, schema_editor):
User = apps.get_model("auth", "User")
Employee = apps.get_model("financial", "Employee")
for user in User.objects.all():
Employee.objects.get_or_create(user_id=user.id)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("financial", "0014_chargenumber_name"),
]
operations = [
migrations.CreateModel(
name="UserProfile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"user_type",
models.CharField(
choices=[("employee", "Employee"), ("client", "Client")],
default="client",
max_length=10,
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="profile",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.RunPython(migrate_user_profiles, reverse_migrate_user_profiles),
]
+47
View File
@@ -243,6 +243,29 @@ class AddressModel(models.Model):
state = models.CharField(max_length=2)
zip_code = models.CharField(max_length=5)
class UserProfile(models.Model):
class UserType(models.TextChoices):
EMPLOYEE = "employee", "Employee"
CLIENT = "client", "Client"
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
user_type = models.CharField(
max_length=10,
choices=UserType.choices,
default=UserType.CLIENT,
)
def __str__(self):
return f"{self.user} ({self.get_user_type_display()})"
def is_employee(self):
return self.user_type == self.UserType.EMPLOYEE
def is_client(self):
return self.user_type == self.UserType.CLIENT
class Employee(IdMixin, TimeMixin):
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
user = models.OneToOneField(User, on_delete=models.CASCADE)
@@ -279,6 +302,30 @@ class TimeCardCell(IdMixin, TimeMixin):
charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True)
def set_user_type(user, user_type):
"""Set user type and sync the Employee record (mutually exclusive types)."""
user.__dict__.pop("profile", None)
profile, _ = UserProfile.objects.get_or_create(
user=user,
defaults={"user_type": user_type},
)
if profile.user_type == user_type:
if user_type == UserProfile.UserType.EMPLOYEE:
Employee.objects.get_or_create(user=user)
return profile
if user_type == UserProfile.UserType.CLIENT:
employee = Employee.objects.filter(user=user).first()
if employee and TimeCardCell.objects.filter(timeCard__employee=employee).exists():
raise ValueError("Cannot set Client: user has time log entries.")
profile.user_type = user_type
profile.save()
user.__dict__.pop("profile", None)
if user_type == UserProfile.UserType.EMPLOYEE:
Employee.objects.get_or_create(user=user)
else:
Employee.objects.filter(user=user).delete()
return profile
+76
View File
@@ -0,0 +1,76 @@
from functools import wraps
from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied
def get_user_profile(user):
if not user.is_authenticated:
return None
from .models import UserProfile
return UserProfile.objects.filter(user_id=user.pk).first()
def is_financial_admin(user):
return user.is_active and user.is_superuser
def is_employee_user(user):
profile = get_user_profile(user)
return bool(profile and profile.is_employee())
def is_client_user(user):
profile = get_user_profile(user)
return bool(profile and profile.is_client())
def has_financial_access(user):
return is_financial_admin(user) or is_employee_user(user) or is_client_user(user)
def can_write_financials(user):
return is_financial_admin(user) or is_employee_user(user)
def get_employees():
from .models import Employee, UserProfile
return Employee.objects.filter(
user__profile__user_type=UserProfile.UserType.EMPLOYEE
)
def get_user_employee(user):
from .models import Employee
if not is_employee_user(user):
return None
return Employee.objects.filter(user=user).first()
def financial_admin_required(view_func):
return user_passes_test(is_financial_admin)(view_func)
def financial_access_required(view_func):
@login_required
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
if has_financial_access(request.user):
return view_func(request, *args, **kwargs)
raise PermissionDenied
return _wrapped
def financial_write_required(view_func):
@login_required
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
if can_write_financials(request.user):
return view_func(request, *args, **kwargs)
raise PermissionDenied
return _wrapped
+8 -4
View File
@@ -2,10 +2,14 @@ from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import UserProfile
@receiver(post_save, sender=User)
def create_employee_for_user(sender, instance, created, **kwargs):
"""Auto-create an Employee record whenever a User is created."""
from financial.models import Employee
def create_profile_for_user(sender, instance, created, **kwargs):
"""Auto-create a UserProfile (default Client) whenever a User is created."""
if created:
Employee.objects.get_or_create(user=instance)
UserProfile.objects.get_or_create(
user=instance,
defaults={"user_type": UserProfile.UserType.CLIENT},
)
@@ -9,6 +9,7 @@
<h1 class="section-title">Dashboard</h1>
<div class="card-grid" style="margin-bottom: 3rem;">
{% if is_financial_admin %}
<a href="{% url 'contracts' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="contracts">
<span class="card-title">View Contracts</span>
@@ -24,15 +25,22 @@
<span class="card-title">New Employee</span>
<p class="card-text">Add a new personnel member to your organization.</p>
</a>
<a href="{% url 'manage_users' %}" class="card">
<span class="card-title">Manage Users</span>
<p class="card-text">Set Employee or Client type for user accounts.</p>
</a>
{% endif %}
{% if can_write_financials %}
<a href="{% url 'Timekeeping' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping">
<span class="card-title">Log Time</span>
<p class="card-text">Record work hours against specific contracts.</p>
</a>
{% endif %}
<a href="{% url 'time_logs' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="time_logs">
<span class="card-title">Manage Time Logs</span>
<p class="card-text">Review and edit submitted time entries.</p>
<span class="card-title">{% if can_write_financials %}Manage{% else %}View{% endif %} Time Logs</span>
<p class="card-text">Review{% if can_write_financials %} and edit{% endif %} submitted time entries.</p>
</a>
<a href="{% url 'client_reports' %}" class="card">
<span class="card-title">Client Reports</span>
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Manage Users - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<h1 class="section-title" style="text-align: left;">Manage Users</h1>
{% if messages %}
{% for message in messages %}
<p style="margin-bottom: 1rem; color: {% if message.tags == 'error' %}#ff6666{% else %}var(--primary-color){% endif %};">
{{ message }}
</p>
{% endfor %}
{% endif %}
<div class="card" style="max-width: 600px; margin-bottom: 2rem;">
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Set User Type</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Update User Type</button>
</form>
</div>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Name</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.get_full_name|default:"—" }}</td>
<td>{{ u.profile.get_user_type_display|default:"Client" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
@@ -7,13 +7,36 @@
<div class="section">
<div class="container">
<h1 class="section-title" style="text-align: left;">Profile</h1>
<div class="card" style="max-width: 600px;">
{% if messages %}
{% for message in messages %}
<p style="margin-bottom: 1rem; color: {% if message.tags == 'error' %}#ff6666{% else %}var(--primary-color){% endif %};">
{{ message }}
</p>
{% endfor %}
{% endif %}
<div class="card" style="max-width: 600px; margin-bottom: 2rem;">
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Account Type</h2>
<p style="color: var(--text-muted); margin-bottom: 1rem;">
Your account is currently: <strong>{{ profile.get_user_type_display }}</strong>
</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
{% if can_edit_type %}
<button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button>
{% endif %}
</form>
</div>
{% if employee_form %}
<div class="card" style="max-width: 600px;">
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Employee Details</h2>
{{ employee_form.as_p }}
<p style="color: var(--text-muted); font-size: 0.9rem;">Contact an admin to update employee details.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -9,11 +9,13 @@
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h1 class="section-title" style="margin-bottom: 0;">All Time Logs</h1>
<div>
<a href="{% url 'financial_index' %}" class="btn"
<a href="{% url 'financial_home' %}" class="btn"
style="padding: 0.5rem 1.5rem; font-size: 0.9rem; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.1);">Back
to Dashboard</a>
{% if can_edit_logs %}
<a href="{% url 'Timekeeping' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Log New
Time</a>
{% endif %}
</div>
</div>
@@ -144,6 +146,7 @@
<td>{{ log.end_time|default_if_none:"" }}</td>
<td>{{ log.hour }}</td>
<td>
{% if can_edit_logs %}
<a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan"
style="margin-right: 10px;">Edit</a>
<form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;"
@@ -152,6 +155,9 @@
<button type="submit"
style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button>
</form>
{% else %}
<span style="color: var(--text-muted);">Read only</span>
{% endif %}
</td>
</tr>
{% empty %}
+158 -2
View File
@@ -1,3 +1,159 @@
from django.test import TestCase
from django.contrib.auth.models import User
from django.test import Client, TestCase
from django.urls import reverse
# Create your tests here.
from financial.models import (
AddressModel,
ChargeNumber,
Contract,
Employee,
TimeCard,
TimeCardCell,
UserProfile,
set_user_type,
)
from financial.permissions import get_employees, is_client_user, is_employee_user
class UserProfileSignalTests(TestCase):
def test_new_user_gets_client_profile_not_employee(self):
user = User.objects.create_user(username="newbie", password="pass")
self.assertTrue(UserProfile.objects.filter(user=user, user_type=UserProfile.UserType.CLIENT).exists())
self.assertFalse(Employee.objects.filter(user=user).exists())
class SetUserTypeTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="worker", password="pass")
UserProfile.objects.filter(user=self.user).delete()
def test_set_employee_creates_employee_record(self):
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
self.assertTrue(Employee.objects.filter(user=self.user).exists())
self.assertEqual(self.user.profile.user_type, UserProfile.UserType.EMPLOYEE)
def test_set_client_removes_employee_without_time_entries(self):
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
set_user_type(self.user, UserProfile.UserType.CLIENT)
self.assertFalse(Employee.objects.filter(user=self.user).exists())
self.assertEqual(
UserProfile.objects.get(user=self.user).user_type,
UserProfile.UserType.CLIENT,
)
def test_cannot_set_client_with_time_entries(self):
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
employee = Employee.objects.get(user=self.user)
contract = Contract.objects.create(
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
name="Test Contract",
)
charge = ChargeNumber.objects.create(
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
contract=contract,
)
time_card = TimeCard.objects.create(employee=employee)
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=2.0)
with self.assertRaises(ValueError):
set_user_type(self.user, UserProfile.UserType.CLIENT)
class EmployeeFilterTests(TestCase):
def setUp(self):
self.employee_user = User.objects.create_user(username="emp", password="pass")
self.client_user = User.objects.create_user(username="cli", password="pass")
set_user_type(self.employee_user, UserProfile.UserType.EMPLOYEE)
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
def test_get_employees_excludes_clients(self):
employees = list(get_employees())
self.assertEqual(len(employees), 1)
self.assertEqual(employees[0].user, self.employee_user)
def test_permission_helpers(self):
self.assertTrue(is_employee_user(self.employee_user))
self.assertFalse(is_employee_user(self.client_user))
self.assertTrue(is_client_user(self.client_user))
self.assertFalse(is_client_user(self.employee_user))
class FinancialAccessTests(TestCase):
def setUp(self):
self.client = Client()
self.admin = User.objects.create_superuser(username="admin", password="pass", email="a@test.com")
self.employee = User.objects.create_user(username="employee", password="pass")
self.client_user = User.objects.create_user(username="client", password="pass")
set_user_type(self.employee, UserProfile.UserType.EMPLOYEE)
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
contract = Contract.objects.create(
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
name="C1",
budget_hours=100,
)
charge = ChargeNumber.objects.create(
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
contract=contract,
)
emp_record = Employee.objects.get(user=self.employee)
time_card = TimeCard.objects.create(employee=emp_record)
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=4.0)
def test_client_can_view_reports_readonly(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("client_reports"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "C1")
def test_client_cannot_log_time(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("Timekeeping"))
self.assertEqual(response.status_code, 403)
def test_client_can_view_time_logs_without_edit(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("time_logs"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Read only")
self.assertNotContains(response, 'href="/financial/time_logs/')
def test_employee_can_access_timekeeping(self):
self.client.login(username="employee", password="pass")
response = self.client.get(reverse("Timekeeping"))
self.assertEqual(response.status_code, 200)
def test_client_redirected_from_financial_home_to_reports(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("financial_home"))
self.assertRedirects(response, reverse("client_reports"))
def test_admin_can_manage_users(self):
self.client.login(username="admin", password="pass")
response = self.client.get(reverse("manage_users"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("manage_users"), {
"user": self.client_user.id,
"user_type": UserProfile.UserType.EMPLOYEE,
})
self.assertRedirects(response, reverse("manage_users"))
self.client_user.refresh_from_db()
self.assertEqual(self.client_user.profile.user_type, UserProfile.UserType.EMPLOYEE)
def test_time_logs_only_lists_employees(self):
self.client.login(username="admin", password="pass")
response = self.client.get(reverse("time_logs"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "employee")
employees = response.context["employees"]
self.assertEqual(employees.count(), 1)
def test_new_user_not_in_employee_dropdown(self):
extra = User.objects.create_user(username="extra", password="pass")
set_user_type(extra, UserProfile.UserType.CLIENT)
self.client.login(username="admin", password="pass")
response = self.client.get(reverse("time_logs"))
employees = response.context["employees"]
usernames = [e.user.username for e in employees]
self.assertIn("employee", usernames)
self.assertNotIn("extra", usernames)
+3 -1
View File
@@ -3,7 +3,8 @@ from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="financial_index"),
path("", views.financial_home, name="financial_home"),
path("dashboard", views.index, name="financial_index"),
path("timekeeping", views.timekeeping, name="Timekeeping"),
path("time_logs", views.time_logs, name="time_logs"),
path("time_logs/<int:log_id>/edit", views.edit_time_log, name="edit_time_log"),
@@ -18,5 +19,6 @@ urlpatterns = [
#path("contracts/<int:contract_id>/", views.contract_detail, name="contract"),
path("procurements", views.procurement, name="procurements"),
path("profile", views.profile, name="profile"),
path("manage_users", views.manage_users, name="manage_users"),
path("client_reports", views.client_reports, name="client_reports"),
]
+142 -32
View File
@@ -1,23 +1,41 @@
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import user_passes_test
from .forms import EmployeeForm, ContractForm, ChargeNumberForm, TimeLogForm, NewEmployeeForm
from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee
from django.contrib.auth.models import User
from django.contrib import messages
from django.utils import timezone
from django.db.models import Sum
from datetime import timedelta
import json
def is_admin(user):
return user.is_active and user.is_superuser
from .forms import (
EmployeeForm,
ContractForm,
ChargeNumberForm,
TimeLogForm,
NewEmployeeForm,
UserProfileForm,
AdminUserTypeForm,
)
from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee, UserProfile
from .permissions import (
financial_admin_required,
financial_access_required,
financial_write_required,
get_employees,
get_user_employee,
is_client_user,
is_financial_admin,
can_write_financials,
)
@user_passes_test(is_admin)
@financial_admin_required
def index(request):
contracts = Contract.objects.all()
for c in contracts:
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum']
c.total_logged = total if total else 0.0
employees = Employee.objects.all()
employees = get_employees()
employee_data = []
for e in employees:
contract_hours = []
@@ -28,10 +46,20 @@ def index(request):
return render(request, "financial/index.html", {
'contracts': contracts,
'employee_data': employee_data
'employee_data': employee_data,
})
@user_passes_test(is_admin)
@financial_access_required
def financial_home(request):
if is_financial_admin(request.user):
return redirect('financial_index')
if is_client_user(request.user):
return redirect('client_reports')
return redirect('Timekeeping')
@financial_admin_required
def new_employee(request):
if request.method == "POST":
form = NewEmployeeForm(request.POST)
@@ -42,7 +70,8 @@ def new_employee(request):
form = NewEmployeeForm()
return render(request, 'financial/new_employee.html', {"form": form})
@user_passes_test(is_admin)
@financial_admin_required
def contracts(request):
contracts_list = Contract.objects.all()
today = timezone.now().date()
@@ -102,7 +131,8 @@ def contracts(request):
'chart_data_json': json.dumps(chart_data_list)
})
@user_passes_test(is_admin)
@financial_admin_required
def contract_detail(request, contract_slug):
contract = Contract.objects.filter(slug=contract_slug).first()
@@ -122,7 +152,6 @@ def contract_detail(request, contract_slug):
lines_str = "\n".join(mermaid_gantt_lines)
mermaid_gantt = f"gantt\n title {contract.name} Charge Numbers Timeline\n dateFormat YYYY-MM-DD\n section Charge Numbers\n{lines_str}"
# --- EVM Data ---
evm = contract.get_evm_data() if contract else {}
evm_chart_json = json.dumps({
'time_series': evm.get('time_series', []),
@@ -149,7 +178,8 @@ def contract_detail(request, contract_slug):
'evm_chart_json': evm_chart_json,
})
@user_passes_test(is_admin)
@financial_admin_required
def new_contract(request):
if request.method == "POST":
form = ContractForm(request.POST)
@@ -160,27 +190,42 @@ def new_contract(request):
form = ContractForm()
return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True})
@user_passes_test(is_admin)
@financial_write_required
def timekeeping(request):
employee = get_user_employee(request.user)
if not employee and not is_financial_admin(request.user):
messages.error(request, "Only employees can log time.")
return redirect('financial_home')
if request.method == "POST":
form = TimeLogForm(request.POST)
if form.is_valid():
employee, _ = Employee.objects.get_or_create(user=request.user)
if is_financial_admin(request.user) and not employee:
messages.error(request, "Admin must have an Employee profile to log time here.")
return redirect('time_logs')
time_card, _ = TimeCard.objects.get_or_create(employee=employee, startDate=timezone.now().date(), endDate=timezone.now().date())
time_card, _ = TimeCard.objects.get_or_create(
employee=employee,
startDate=timezone.now().date(),
endDate=timezone.now().date(),
)
cell = form.save(commit=False)
cell.timeCard = time_card
cell.save()
return redirect('financial_index')
return redirect('financial_home')
else:
form = TimeLogForm()
return render(request, 'financial/timekeeping.html', {'form': form})
@user_passes_test(is_admin)
@financial_access_required
def time_logs(request):
logs = TimeCardCell.objects.select_related(
'timeCard__employee__user',
'charge_number__contract',
).filter(
timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE,
).order_by('-date', '-created')
employee_ids = []
@@ -231,12 +276,13 @@ def time_logs(request):
return render(request, 'financial/time_logs.html', {
'logs': logs,
'employees': Employee.objects.select_related('user').order_by('user__last_name', 'user__first_name'),
'employees': get_employees().select_related('user').order_by('user__last_name', 'user__first_name'),
'contracts': Contract.objects.order_by('name'),
'charge_numbers': charge_numbers,
'contract_totals': contract_totals,
'charge_number_totals': charge_number_totals,
'grand_total': grand_total,
'can_edit_logs': can_write_financials(request.user),
'filters': {
'employees': [str(eid) for eid in employee_ids],
'month': month or '',
@@ -245,7 +291,8 @@ def time_logs(request):
},
})
@user_passes_test(is_admin)
@financial_write_required
def edit_time_log(request, log_id):
log_entry = TimeCardCell.objects.filter(id=log_id).first()
if not log_entry:
@@ -261,7 +308,8 @@ def edit_time_log(request, log_id):
return render(request, 'financial/edit_time_log.html', {'form': form, 'log': log_entry})
@user_passes_test(is_admin)
@financial_write_required
def delete_time_log(request, log_id):
if request.method == "POST":
log_entry = TimeCardCell.objects.filter(id=log_id).first()
@@ -269,16 +317,24 @@ def delete_time_log(request, log_id):
log_entry.delete()
return redirect('time_logs')
@user_passes_test(is_admin)
@financial_access_required
def client_reports(request):
contracts = Contract.objects.all()
for c in contracts:
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum']
total = TimeCardCell.objects.filter(
charge_number__contract=c,
timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE,
).aggregate(Sum('hour'))['hour__sum']
c.total_logged = total if total else 0.0
c.remaining_budget = c.budget_hours - c.total_logged
return render(request, 'financial/reports.html', {'contracts': contracts})
return render(request, 'financial/reports.html', {
'contracts': contracts,
'read_only': is_client_user(request.user),
})
@user_passes_test(is_admin)
@financial_admin_required
def update_charge_number(request, charge_number_slug):
charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first()
if not charge_number:
@@ -297,7 +353,8 @@ def update_charge_number(request, charge_number_slug):
'charge_number': charge_number,
})
@user_passes_test(is_admin)
@financial_admin_required
def new_charge_number(request, contract_slug):
contract = Contract.objects.filter(slug=contract_slug).first()
if request.method == "POST":
@@ -309,19 +366,72 @@ def new_charge_number(request, contract_slug):
return redirect('contract_detail', contract_slug=contract.slug)
return redirect('contract_detail', contract_slug=contract_slug)
@user_passes_test(is_admin)
@financial_admin_required
def timeapproval(request):
return render(request, 'financial/not_created.html', {})
@user_passes_test(is_admin)
@financial_admin_required
def chargenumber(request):
return render(request, 'financial/not_created.html', {})
@user_passes_test(is_admin)
@financial_admin_required
def procurement(request):
return render(request, 'financial/procurement.html', {})
@user_passes_test(is_admin)
@financial_access_required
def profile(request):
form = EmployeeForm()
return render(request, 'financial/profile.html', {'form': form})
profile_obj, _ = UserProfile.objects.get_or_create(user=request.user)
can_edit_type = is_financial_admin(request.user)
if request.method == "POST" and can_edit_type:
form = UserProfileForm(request.POST, instance=profile_obj)
if form.is_valid():
from .models import set_user_type
try:
set_user_type(request.user, form.cleaned_data['user_type'])
messages.success(request, "Profile updated.")
except ValueError as exc:
messages.error(request, str(exc))
return redirect('profile')
else:
form = UserProfileForm(instance=profile_obj)
if not can_edit_type:
form.fields['user_type'].disabled = True
employee = Employee.objects.filter(user=request.user).first()
employee_form = None
if employee and profile_obj.is_employee():
employee_form = EmployeeForm(instance=employee)
return render(request, 'financial/profile.html', {
'form': form,
'employee_form': employee_form,
'profile': profile_obj,
'can_edit_type': can_edit_type,
})
@financial_admin_required
def manage_users(request):
if request.method == "POST":
form = AdminUserTypeForm(request.POST)
if form.is_valid():
from .models import set_user_type
try:
set_user_type(form.cleaned_data['user'], form.cleaned_data['user_type'])
messages.success(request, "User type updated.")
return redirect('manage_users')
except ValueError as exc:
messages.error(request, str(exc))
else:
form = AdminUserTypeForm()
users = User.objects.select_related('profile').order_by('username')
return render(request, 'financial/manage_users.html', {
'form': form,
'users': users,
})
+62
View File
@@ -1,4 +1,9 @@
import json
from django.conf import settings
from django.urls import reverse
from .seo import PUBLIC_PAGE_ENTRIES, get_service_entries
def tianji_tracking(request):
@@ -16,3 +21,60 @@ def tianji_tracking(request):
),
'page_name': getattr(getattr(request, 'resolver_match', None), 'url_name', ''),
}
def webmcp_context(request):
page_name = getattr(getattr(request, 'resolver_match', None), 'url_name', '')
services = [
{
**entry,
"url": request.build_absolute_uri(reverse(entry["slug"])),
}
for entry in get_service_entries()
]
page_lookup = {
url_name: {
"slug": url_name,
"name": title,
"summary": summary,
"url": request.build_absolute_uri(reverse(url_name)),
}
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
}
return {
'webmcp_enabled': getattr(settings, 'WEBMCP_ENABLED', False),
'webmcp_page_name': page_name,
'webmcp_contact_url': request.build_absolute_uri(reverse('contact')),
'webmcp_recaptcha_required': not settings.DEBUG,
'webmcp_services_json': json.dumps(services),
'webmcp_pages_json': json.dumps(page_lookup),
}
def financial_access(request):
user = request.user
if not user.is_authenticated:
return {
'is_financial_admin': False,
'is_employee_user': False,
'is_client_user': False,
'can_write_financials': False,
'has_financial_access': False,
}
from financial.permissions import (
can_write_financials,
has_financial_access,
is_client_user,
is_employee_user,
is_financial_admin,
)
return {
'is_financial_admin': is_financial_admin(user),
'is_employee_user': is_employee_user(user),
'is_client_user': is_client_user(user),
'can_write_financials': can_write_financials(user),
'has_financial_access': has_financial_access(user),
}
+2 -1
View File
@@ -7,7 +7,8 @@ class FormWithCaptcha(forms.Form):
captcha = ReCaptchaField(
widget=ReCaptchaV3(
attrs={
'required_score':0.85,
'required_score': 0.85,
'form': 'contact-form',
}
),
public_key=settings.RECAPTCHA_PUBLIC_KEY,
+112 -14
View File
@@ -5,21 +5,119 @@ from django.template.loader import render_to_string
from django.urls import reverse
# Public marketing pages included in sitemap and llms.txt.
# Tuple: (url_name, title, changefreq, priority, summary)
PUBLIC_PAGE_ENTRIES = (
("public_index", "Home", "weekly", "1.0"),
("forward_deployed", "Forward-Deployed AI", "monthly", "0.9"),
("bot", "AI Agents", "monthly", "0.9"),
("ml_model", "ML Models", "monthly", "0.8"),
("chat", "Secure AI Chat", "monthly", "0.8"),
("ai_sensor", "AI Sensor Algorithms", "monthly", "0.7"),
("ai_education", "AI Education", "monthly", "0.7"),
("computers", "Computer Builds", "monthly", "0.7"),
("file_hosting", "File Hosting", "monthly", "0.7"),
("web_design", "Web Design and Hosting", "monthly", "0.8"),
("contact", "Contact", "monthly", "0.9"),
("terms_of_service", "Terms of Service and Privacy", "yearly", "0.3"),
(
"public_index",
"Home",
"weekly",
"1.0",
"Company homepage with an overview of AI ML Operations services.",
),
(
"forward_deployed",
"Forward-Deployed AI",
"monthly",
"0.9",
"Embedded AI engineering — we work inside your environment to build production systems.",
),
(
"bot",
"AI Agents",
"monthly",
"0.9",
"Custom AI agents and agentic workflows tailored to your operational bottlenecks.",
),
(
"ml_model",
"ML Models",
"monthly",
"0.8",
"Machine learning model development, training, and deployment for production use.",
),
(
"chat",
"Secure AI Chat",
"monthly",
"0.8",
"Private, hosted AI chat deployments with enterprise-grade security.",
),
(
"ai_sensor",
"AI Sensor Algorithms",
"monthly",
"0.7",
"Computer vision and sensor-fusion algorithms for real-world sensing applications.",
),
(
"ai_education",
"AI Education",
"monthly",
"0.7",
"Hands-on AI training and workshops for teams adopting agentic workflows.",
),
(
"computers",
"Computer Builds",
"monthly",
"0.7",
"Custom workstation and server builds optimized for AI and ML workloads.",
),
(
"file_hosting",
"File Hosting",
"monthly",
"0.7",
"Managed file hosting and storage for teams that need reliable data access.",
),
(
"web_design",
"Web Design and Hosting",
"monthly",
"0.8",
"Web design, development, and managed hosting for business sites and apps.",
),
(
"contact",
"Contact",
"monthly",
"0.9",
"Contact form to inquire about forward-deployed AI engineering services.",
),
(
"terms_of_service",
"Terms of Service and Privacy",
"yearly",
"0.3",
"Terms of service and privacy policy for AI ML Operations, LLC.",
),
)
SERVICE_URL_NAMES = frozenset({
"forward_deployed",
"bot",
"ml_model",
"chat",
"ai_sensor",
"ai_education",
"computers",
"file_hosting",
"web_design",
})
def get_service_entries():
"""Return service page metadata for WebMCP navigation tools."""
return [
{
"slug": url_name,
"name": title,
"summary": summary,
}
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
if url_name in SERVICE_URL_NAMES
]
def _absolute_url(request, url_name):
return request.build_absolute_uri(reverse(url_name))
@@ -41,7 +139,7 @@ def sitemap_xml(request):
"changefreq": changefreq,
"priority": priority,
}
for url_name, _title, changefreq, priority in PUBLIC_PAGE_ENTRIES
for url_name, _title, changefreq, priority, _summary in PUBLIC_PAGE_ENTRIES
]
content = render_to_string("public/sitemap.xml", {"pages": pages})
return HttpResponse(content, content_type="application/xml; charset=utf-8")
@@ -53,7 +151,7 @@ def llms_txt(request):
"title": title,
"url": _absolute_url(request, url_name),
}
for url_name, title, _changefreq, _priority in PUBLIC_PAGE_ENTRIES
for url_name, title, _changefreq, _priority, _summary in PUBLIC_PAGE_ENTRIES
]
content = render_to_string(
"public/llms.txt",
@@ -780,6 +780,14 @@ input:focus, select:focus, textarea:focus {
margin: 0 0 1rem 1.5rem;
}
.contact-captcha-group {
margin-top: 1rem;
}
.contact-captcha-group + .btn {
margin-top: 1rem;
}
/* Cookie consent banner */
.cookie-consent-banner {
position: fixed;
@@ -0,0 +1,286 @@
(function () {
'use strict';
var configEl = document.getElementById('webmcp-config');
if (!configEl) {
return;
}
var modelContext = navigator.modelContext || document.modelContext;
if (!modelContext || typeof modelContext.registerTool !== 'function') {
return;
}
var contactUrl = configEl.dataset.contactUrl || '/contact';
var pageName = configEl.dataset.pageName || '';
var recaptchaRequired = configEl.dataset.recaptchaRequired === 'true';
var services = [];
var pages = {};
try {
services = JSON.parse(configEl.dataset.services || '[]');
} catch (e) {
services = [];
}
try {
pages = JSON.parse(configEl.dataset.pages || '{}');
} catch (e) {
pages = {};
}
function textResult(payload) {
return {
content: [{
type: 'text',
text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2),
}],
};
}
function getCsrfToken() {
var match = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
function resolvePageUrl(slug) {
var page = pages[slug];
if (page && page.url) {
return page.url;
}
var service = services.find(function (entry) {
return entry.slug === slug;
});
return service ? service.url : null;
}
function findPage(query) {
var normalized = String(query || '').trim().toLowerCase();
if (!normalized) {
return null;
}
var keys = Object.keys(pages);
for (var i = 0; i < keys.length; i += 1) {
var slug = keys[i];
var page = pages[slug];
if (
slug.toLowerCase() === normalized ||
page.name.toLowerCase() === normalized ||
page.name.toLowerCase().indexOf(normalized) !== -1
) {
return {
slug: slug,
name: page.name,
summary: page.summary,
url: page.url || resolvePageUrl(slug),
};
}
}
return null;
}
async function getRecaptchaToken() {
if (!recaptchaRequired) {
return null;
}
if (!window.grecaptcha || typeof window.grecaptcha.execute !== 'function') {
throw new Error(
'reCAPTCHA is required in production. Use the HTML contact form in a browser session, ' +
'or enable DEBUG for audit demos without captcha.'
);
}
var widget = document.querySelector('.g-recaptcha');
var siteKey = widget ? widget.getAttribute('data-sitekey') : null;
if (!siteKey) {
var script = document.querySelector('script[src*="recaptcha"]');
if (script && script.src) {
var keyMatch = script.src.match(/[?&]render=([^&]+)/);
siteKey = keyMatch ? decodeURIComponent(keyMatch[1]) : null;
}
}
if (!siteKey) {
throw new Error('reCAPTCHA site key not found on the contact page.');
}
return window.grecaptcha.execute(siteKey, { action: 'contact' });
}
async function submitContactInquiry(input) {
var formData = new FormData();
formData.append('name', input.name);
formData.append('email', input.email);
formData.append('subject', input.subject);
formData.append('message', input.message || '');
formData.append('csrfmiddlewaretoken', getCsrfToken());
if (recaptchaRequired) {
var token = await getRecaptchaToken();
formData.append('g-recaptcha-response', token);
}
var response = await fetch(contactUrl, {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
var html = await response.text();
if (html.indexOf("We'll be in contact shortly!") !== -1) {
return textResult({
success: true,
message: 'Contact inquiry submitted successfully.',
});
}
if (html.indexOf('There was an error submitting') !== -1) {
return textResult({
success: false,
error: 'Submission failed validation (often reCAPTCHA in production).',
});
}
if (html.indexOf('is required') !== -1) {
return textResult({
success: false,
error: 'Missing required fields: name, email, and subject are required.',
});
}
return textResult({
success: false,
error: 'Unexpected response from contact endpoint.',
status: response.status,
});
}
modelContext.registerTool({
name: 'list_services',
description: 'List AI ML Operations service pages with name, URL, and summary.',
inputSchema: {
type: 'object',
properties: {},
},
annotations: { readOnlyHint: true },
execute: function () {
var results = services.map(function (service) {
return {
name: service.name,
slug: service.slug,
url: service.url || resolvePageUrl(service.slug),
summary: service.summary,
};
});
return textResult({ services: results });
},
});
modelContext.registerTool({
name: 'get_page_content',
description: 'Look up a public page by slug or service name and return its URL and summary.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Page slug (e.g. forward_deployed) or service name (e.g. AI Agents).',
},
},
required: ['query'],
},
annotations: { readOnlyHint: true },
execute: function (input) {
var page = findPage(input.query);
if (!page) {
return textResult({
success: false,
error: 'No page found for query: ' + input.query,
});
}
return textResult({ success: true, page: page });
},
});
modelContext.registerTool({
name: 'navigate_to_service',
description: 'Resolve a service slug or name to its canonical marketing page URL.',
inputSchema: {
type: 'object',
properties: {
service: {
type: 'string',
description: 'Service slug or display name.',
},
},
required: ['service'],
},
annotations: { readOnlyHint: true },
execute: function (input) {
var page = findPage(input.service);
if (!page) {
return textResult({
success: false,
error: 'Unknown service: ' + input.service,
});
}
return textResult({
success: true,
url: page.url,
name: page.name,
summary: page.summary,
});
},
});
modelContext.registerTool({
name: 'open_contact_with_subject',
description: 'Build a contact page URL with a pre-filled subject for hosting, pricing, or service inquiries.',
inputSchema: {
type: 'object',
properties: {
subject: {
type: 'string',
description: 'Subject line to pre-fill on the contact form.',
},
},
required: ['subject'],
},
annotations: { readOnlyHint: true },
execute: function (input) {
var url = new URL(contactUrl, window.location.origin);
url.searchParams.set('subject', input.subject);
return textResult({
success: true,
url: url.toString(),
subject: input.subject,
});
},
});
if (pageName === 'contact') {
modelContext.registerTool({
name: 'submit_contact_inquiry',
description: 'Submit a contact inquiry to AI ML Operations. Requires name, email, and subject.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Sender full name.' },
email: { type: 'string', description: 'Sender email address.' },
subject: { type: 'string', description: 'Inquiry subject line.' },
message: { type: 'string', description: 'Optional message body.' },
},
required: ['name', 'email', 'subject'],
},
annotations: { readOnlyHint: false },
execute: submitContactInquiry,
});
}
})();
+23 -1
View File
@@ -44,6 +44,16 @@
{% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %}
hidden></div>
{% endif %}
{% if webmcp_enabled %}
<div id="webmcp-config"
data-page-name="{{ webmcp_page_name }}"
data-contact-url="{{ webmcp_contact_url }}"
data-recaptcha-required="{{ webmcp_recaptcha_required|yesno:'true,false' }}"
data-services='{{ webmcp_services_json|escapejs }}'
data-pages='{{ webmcp_pages_json|escapejs }}'
hidden></div>
{% endif %}
</head>
<body>
@@ -79,9 +89,11 @@
<li><a href="{% url 'planning:board_view' %}"
class="{% if 'planning' in request.path %}active{% endif %}"
data-tianji-event="nav_planning">Planning</a></li>
<li><a href="{% url 'financial_index' %}"
{% if has_financial_access %}
<li><a href="{% url 'financial_home' %}"
class="{% if 'financial' in request.path %}active{% endif %}"
data-tianji-event="nav_financials">Financials</a></li>
{% endif %}
<li class="dropdown" id="user-profile-dropdown">
<button type="button" class="profile-icon-link" aria-label="Account menu for {{ user.get_full_name|default:user.username }}"
aria-expanded="false" aria-haspopup="true" aria-controls="profile-menu">
@@ -93,6 +105,12 @@
</button>
<ul class="dropdown-content profile-dropdown-content" id="profile-menu" role="menu">
<li class="profile-name-item">{{ user.get_full_name|default:user.username }}</li>
{% if has_financial_access %}
<li><a href="{% url 'profile' %}">Profile</a></li>
{% endif %}
{% if is_financial_admin %}
<li><a href="{% url 'manage_users' %}">Manage Users</a></li>
{% endif %}
<li><a href="{% url 'change_password' %}">Change Password</a></li>
<li>
<form action="{% url 'logout' %}" method="post" style="margin: 0;">
@@ -151,6 +169,10 @@
<script src="{% static 'public/js/tianji-consent.js' %}"></script>
{% endif %}
{% if webmcp_enabled %}
<script src="{% static 'public/js/webmcp-tools.js' %}"></script>
{% endif %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const mobileBtn = document.querySelector('.mobile-menu-btn');
@@ -0,0 +1,8 @@
{% include "django_recaptcha/includes/js_v3.html" %}
<input
type="hidden"
name="{{ widget.name }}"
class="g-recaptcha"
form="contact-form"
{% for name, value in widget.attrs.items %}{% if value is not False %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %}
>
@@ -33,40 +33,53 @@
<div class="card">
<h2 class="card-title contact-form-heading" id="contact-form-heading" style="margin-bottom: 2rem;">Send Us a Message</h2>
<form action="{% url 'contact' %}" method="POST" aria-labelledby="contact-form-heading">
<form id="contact-form" action="{% url 'contact' %}" method="POST" aria-labelledby="contact-form-heading"
toolname="submit_contact_inquiry"
tooldescription="Submit a contact inquiry to AI ML Operations">
{% csrf_token %}
<div class="form-row">
<div class="form-group">
<label class="form-label" for="contact-name">Your Name</label>
<input type="text" class="form-control" name="name" id="contact-name" autocomplete="name" required>
<input type="text" class="form-control" name="name" id="contact-name" autocomplete="name" required
toolparamdescription="Full name of the person submitting the inquiry.">
</div>
<div class="form-group">
<label class="form-label" for="contact-email">Your Email</label>
<input type="email" class="form-control" name="email" id="contact-email" autocomplete="email" required>
<input type="email" class="form-control" name="email" id="contact-email" autocomplete="email" required
toolparamdescription="Email address where AI ML Operations can reply.">
</div>
</div>
<div class="form-group">
<label class="form-label" for="contact-subject">Subject</label>
<input type="text" class="form-control" name="subject" id="contact-subject" value="{{ request.GET.subject|default:'' }}" required>
<input type="text" class="form-control" name="subject" id="contact-subject" value="{{ request.GET.subject|default:'' }}" required
toolparamdescription="Short summary of the inquiry topic or service of interest.">
</div>
<div class="form-group">
<label class="form-label" for="contact-message">Message</label>
<textarea name="message" class="form-control" id="contact-message" rows="5" placeholder="What workflow is costing you the most time? What systems does it touch?"></textarea>
</div>
<div class="form-group">
{% if capchaForm %}
{{ capchaForm }}
{% endif %}
<textarea name="message" class="form-control" id="contact-message" rows="5"
placeholder="What workflow is costing you the most time? What systems does it touch?"
toolparamdescription="Optional details about the workflow, bottleneck, or systems involved."></textarea>
</div>
{% if not capchaForm %}
<button class="btn" type="submit"
data-tianji-event="contact_form_submit">
Send Message
</button>
{% endif %}
</form>
{% if capchaForm %}
<div class="form-group contact-captcha-group">
{{ capchaForm }}
</div>
<button class="btn" type="submit" form="contact-form"
data-tianji-event="contact_form_submit">
Send Message
</button>
{% endif %}
</div>
</div>
+72
View File
@@ -4,6 +4,7 @@ from django.test import Client, TestCase, override_settings
from django.urls import reverse
from .models import Contact
from .seo import SERVICE_URL_NAMES, get_service_entries
@override_settings(
@@ -170,3 +171,74 @@ class AgenticBrowsingSeoTests(TestCase):
self.assertContains(response, 'id="services-menu-button"')
self.assertContains(response, 'aria-haspopup="true"')
self.assertContains(response, 'aria-label="Main navigation"')
@override_settings(DEBUG=True, WEBMCP_ENABLED=True)
class WebMcpTests(TestCase):
def test_homepage_includes_webmcp_when_enabled(self):
response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "webmcp-config")
self.assertContains(response, "webmcp-tools.js")
self.assertContains(response, 'data-page-name="public_index"')
self.assertContains(response, "forward_deployed")
@override_settings(WEBMCP_ENABLED=False)
def test_homepage_omits_webmcp_when_disabled(self):
response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "webmcp-config")
self.assertNotContains(response, "webmcp-tools.js")
def test_contact_page_includes_webmcp_form_annotations(self):
response = self.client.get(reverse("contact"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'id="contact-form"')
self.assertContains(response, 'toolname="submit_contact_inquiry"')
self.assertContains(response, "tooldescription=")
self.assertContains(response, "toolparamdescription=")
self.assertContains(response, "submit_contact_inquiry")
@override_settings(DEBUG=False)
def test_contact_page_renders_captcha_outside_annotated_form(self):
response = self.client.get(reverse("contact"))
self.assertEqual(response.status_code, 200)
form_end = response.content.index(b"</form>")
captcha_index = response.content.index(b"g-recaptcha")
self.assertGreater(captcha_index, form_end)
self.assertContains(response, b'form="contact-form"')
def test_webmcp_services_json_includes_all_service_pages(self):
response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200)
for slug in SERVICE_URL_NAMES:
self.assertContains(response, slug)
def test_service_entries_match_public_seo_config(self):
entries = get_service_entries()
self.assertEqual(len(entries), len(SERVICE_URL_NAMES))
self.assertEqual({entry["slug"] for entry in entries}, SERVICE_URL_NAMES)
def test_webmcp_static_script_defines_expected_tools(self):
from pathlib import Path
script_path = Path(__file__).resolve().parent / "static/public/js/webmcp-tools.js"
script = script_path.read_text(encoding="utf-8")
for tool_name in (
"list_services",
"get_page_content",
"navigate_to_service",
"open_contact_with_subject",
"submit_contact_inquiry",
):
self.assertIn("name: '" + tool_name + "'", script)
self.assertIn("readOnlyHint: true", script)
self.assertIn("readOnlyHint: false", script)
self.assertIn("navigator.modelContext || document.modelContext", script)
+30
View File
@@ -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:
+31
View File
@@ -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:
+26
View File
@@ -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
-20
View File
@@ -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
+54 -12
View File
@@ -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 <checkout-directory>" >&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
+48
View File
@@ -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}"
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${1:?Usage: $0 <env-file>}"
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)."
Generated
+363
View File
@@ -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" },
]