Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07cba8bc7f | ||
|
|
8ccf17655d | ||
|
|
04d842d799 | ||
|
|
ae0f8a8bc5 | ||
|
|
16dfb3faae | ||
|
|
a5fa08d4a0 | ||
|
|
9a383c0ee9 | ||
|
|
6f97f6084d | ||
|
|
426cc82f04 | ||
|
|
7dd5ec3be1 | ||
|
|
2fb5204614 | ||
|
|
bb5aa6de82 | ||
|
|
7c9aba7e6c |
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
**/__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
db.sqlite3
|
||||||
|
.env
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
*.log
|
||||||
|
staticfiles/
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+28
-16
@@ -1,33 +1,45 @@
|
|||||||
name: Deploy Company Site
|
name: Deploy Company Site
|
||||||
|
|
||||||
|
# Runs after Unit Tests completes on master. Direct pushes only (not PRs).
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: [Unit Tests]
|
||||||
|
types: [completed]
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
docker:
|
||||||
|
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ gitea.event.workflow_run.head_sha }}
|
||||||
|
|
||||||
- name: Set up Python environment
|
- name: Build Docker image
|
||||||
run: |
|
run: docker compose build
|
||||||
python3 -m venv .venv
|
|
||||||
.venv/bin/pip install --upgrade pip
|
|
||||||
.venv/bin/pip install -r requirements.txt
|
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run containerized tests
|
||||||
run: |
|
run: |
|
||||||
cd company_site
|
docker compose up -d db
|
||||||
../.venv/bin/python manage.py test
|
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:
|
deploy:
|
||||||
needs: test
|
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
|
needs: docker
|
||||||
|
env:
|
||||||
|
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Deploy company_site prod
|
||||||
uses: actions/checkout@v4
|
run: |
|
||||||
|
"${SERVER_INFRA_ROOT}/scripts/deploy.sh" \
|
||||||
- name: Deploy to live site
|
--app company_site \
|
||||||
run: bash scripts/deploy.sh "${{ gitea.workspace }}"
|
--env prod \
|
||||||
|
--ref "${{ gitea.event.workflow_run.head_sha }}"
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
@@ -1,4 +1,58 @@
|
|||||||
# company_site
|
# company_site
|
||||||
|
|
||||||
Django site for compan
|
Django site for AIML Operations.
|
||||||
Somethinf
|
|
||||||
|
## 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>`.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -25,8 +25,17 @@ Test at minimum:
|
|||||||
| **sitemap.xml** | `GET /sitemap.xml` — public marketing URLs |
|
| **sitemap.xml** | `GET /sitemap.xml` — public marketing URLs |
|
||||||
| **Accessibility tree** | Form labels, semantic nav controls, ARIA on dialogs |
|
| **Accessibility tree** | Form labels, semantic nav controls, ARIA on dialogs |
|
||||||
| **Layout stability** | Fixed cookie banner, reserved hero/marquee space, font fallbacks |
|
| **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
|
## 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`.
|
2. Nav dropdowns use `<button>` triggers with `aria-expanded` / `aria-haspopup`.
|
||||||
3. Cookie consent banner stays `position: fixed` (no document flow shift).
|
3. Cookie consent banner stays `position: fixed` (no document flow shift).
|
||||||
4. `GET /robots.txt`, `/sitemap.xml`, `/llms.txt` return 200.
|
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
|
## References
|
||||||
|
|
||||||
- [Lighthouse Agentic Browsing audit overview](https://locomotive.agency/blog/lighthouse-agentic-browsing-audit/)
|
- [Lighthouse Agentic Browsing audit overview](https://locomotive.agency/blog/lighthouse-agentic-browsing-audit/)
|
||||||
- [llms.txt proposal](https://llmstxt.org/)
|
- [llms.txt proposal](https://llmstxt.org/)
|
||||||
- Gitea issue #5
|
- [WebMCP tool catalog](webmcp.md)
|
||||||
|
- Gitea issue #5 (foundational), issue #9 (WebMCP)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell
|
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell, UserProfile
|
||||||
# Register your models here.
|
|
||||||
|
|
||||||
class ContractAdmin(admin.ModelAdmin):
|
class ContractAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
@@ -8,6 +7,10 @@ class ContractAdmin(admin.ModelAdmin):
|
|||||||
class EmployeeAdmin(admin.ModelAdmin):
|
class EmployeeAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class UserProfileAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "user_type")
|
||||||
|
list_filter = ("user_type",)
|
||||||
|
|
||||||
class ChargeNumberAdmin(admin.ModelAdmin):
|
class ChargeNumberAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -19,6 +22,7 @@ class TimeCardCellAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
admin.site.register(Contract, ContractAdmin)
|
admin.site.register(Contract, ContractAdmin)
|
||||||
admin.site.register(Employee, EmployeeAdmin)
|
admin.site.register(Employee, EmployeeAdmin)
|
||||||
|
admin.site.register(UserProfile, UserProfileAdmin)
|
||||||
admin.site.register(ChargeNumber, ChargeNumberAdmin)
|
admin.site.register(ChargeNumber, ChargeNumberAdmin)
|
||||||
admin.site.register(TimeCard, TimeCardAdmin)
|
admin.site.register(TimeCard, TimeCardAdmin)
|
||||||
admin.site.register(TimeCardCell, TimeCardCellAdmin)
|
admin.site.register(TimeCardCell, TimeCardCellAdmin)
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.forms import ModelForm
|
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):
|
class NewEmployeeForm(ModelForm):
|
||||||
first_name = forms.CharField(max_length=30, required=False, label="First Name")
|
first_name = forms.CharField(max_length=30, required=False, label="First Name")
|
||||||
@@ -37,6 +38,7 @@ class NewEmployeeForm(ModelForm):
|
|||||||
employee.workAddress = address
|
employee.workAddress = address
|
||||||
if commit:
|
if commit:
|
||||||
employee.save()
|
employee.save()
|
||||||
|
set_user_type(employee.user, UserProfile.UserType.EMPLOYEE)
|
||||||
return employee
|
return employee
|
||||||
|
|
||||||
class EmployeeForm(ModelForm):
|
class EmployeeForm(ModelForm):
|
||||||
@@ -44,6 +46,15 @@ class EmployeeForm(ModelForm):
|
|||||||
model = Employee
|
model = Employee
|
||||||
fields = ["user", "manager", "primaryAddress", "workAddress", "phoneNumber", "slary"]
|
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 ContractForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Contract
|
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),
|
||||||
|
]
|
||||||
@@ -243,6 +243,29 @@ class AddressModel(models.Model):
|
|||||||
state = models.CharField(max_length=2)
|
state = models.CharField(max_length=2)
|
||||||
zip_code = models.CharField(max_length=5)
|
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):
|
class Employee(IdMixin, TimeMixin):
|
||||||
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
|
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
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)
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,10 +2,14 @@ from django.db.models.signals import post_save
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from .models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
def create_employee_for_user(sender, instance, created, **kwargs):
|
def create_profile_for_user(sender, instance, created, **kwargs):
|
||||||
"""Auto-create an Employee record whenever a User is created."""
|
"""Auto-create a UserProfile (default Client) whenever a User is created."""
|
||||||
from financial.models import Employee
|
|
||||||
if 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>
|
<h1 class="section-title">Dashboard</h1>
|
||||||
|
|
||||||
<div class="card-grid" style="margin-bottom: 3rem;">
|
<div class="card-grid" style="margin-bottom: 3rem;">
|
||||||
|
{% if is_financial_admin %}
|
||||||
<a href="{% url 'contracts' %}" class="card"
|
<a href="{% url 'contracts' %}" class="card"
|
||||||
data-tianji-event="financial_nav" data-tianji-event-destination="contracts">
|
data-tianji-event="financial_nav" data-tianji-event-destination="contracts">
|
||||||
<span class="card-title">View Contracts</span>
|
<span class="card-title">View Contracts</span>
|
||||||
@@ -24,15 +25,22 @@
|
|||||||
<span class="card-title">New Employee</span>
|
<span class="card-title">New Employee</span>
|
||||||
<p class="card-text">Add a new personnel member to your organization.</p>
|
<p class="card-text">Add a new personnel member to your organization.</p>
|
||||||
</a>
|
</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"
|
<a href="{% url 'Timekeeping' %}" class="card"
|
||||||
data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping">
|
data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping">
|
||||||
<span class="card-title">Log Time</span>
|
<span class="card-title">Log Time</span>
|
||||||
<p class="card-text">Record work hours against specific contracts.</p>
|
<p class="card-text">Record work hours against specific contracts.</p>
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
<a href="{% url 'time_logs' %}" class="card"
|
<a href="{% url 'time_logs' %}" class="card"
|
||||||
data-tianji-event="financial_nav" data-tianji-event-destination="time_logs">
|
data-tianji-event="financial_nav" data-tianji-event-destination="time_logs">
|
||||||
<span class="card-title">Manage Time Logs</span>
|
<span class="card-title">{% if can_write_financials %}Manage{% else %}View{% endif %} Time Logs</span>
|
||||||
<p class="card-text">Review and edit submitted time entries.</p>
|
<p class="card-text">Review{% if can_write_financials %} and edit{% endif %} submitted time entries.</p>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'client_reports' %}" class="card">
|
<a href="{% url 'client_reports' %}" class="card">
|
||||||
<span class="card-title">Client Reports</span>
|
<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="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="section-title" style="text-align: left;">Profile</h1>
|
<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">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{{ form.as_p }}
|
||||||
|
{% if can_edit_type %}
|
||||||
<button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button>
|
<button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -9,11 +9,13 @@
|
|||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
<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>
|
<h1 class="section-title" style="margin-bottom: 0;">All Time Logs</h1>
|
||||||
<div>
|
<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
|
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>
|
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
|
<a href="{% url 'Timekeeping' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Log New
|
||||||
Time</a>
|
Time</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -144,6 +146,7 @@
|
|||||||
<td>{{ log.end_time|default_if_none:"" }}</td>
|
<td>{{ log.end_time|default_if_none:"" }}</td>
|
||||||
<td>{{ log.hour }}</td>
|
<td>{{ log.hour }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
{% if can_edit_logs %}
|
||||||
<a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan"
|
<a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan"
|
||||||
style="margin-right: 10px;">Edit</a>
|
style="margin-right: 10px;">Edit</a>
|
||||||
<form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;"
|
<form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;"
|
||||||
@@ -152,6 +155,9 @@
|
|||||||
<button type="submit"
|
<button type="submit"
|
||||||
style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button>
|
style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<span style="color: var(--text-muted);">Read only</span>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -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,7 +3,8 @@ from django.urls import path
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
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("timekeeping", views.timekeeping, name="Timekeeping"),
|
||||||
path("time_logs", views.time_logs, name="time_logs"),
|
path("time_logs", views.time_logs, name="time_logs"),
|
||||||
path("time_logs/<int:log_id>/edit", views.edit_time_log, name="edit_time_log"),
|
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("contracts/<int:contract_id>/", views.contract_detail, name="contract"),
|
||||||
path("procurements", views.procurement, name="procurements"),
|
path("procurements", views.procurement, name="procurements"),
|
||||||
path("profile", views.profile, name="profile"),
|
path("profile", views.profile, name="profile"),
|
||||||
|
path("manage_users", views.manage_users, name="manage_users"),
|
||||||
path("client_reports", views.client_reports, name="client_reports"),
|
path("client_reports", views.client_reports, name="client_reports"),
|
||||||
]
|
]
|
||||||
+142
-32
@@ -1,23 +1,41 @@
|
|||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from django.contrib.auth.decorators import user_passes_test
|
from django.contrib.auth.models import User
|
||||||
from .forms import EmployeeForm, ContractForm, ChargeNumberForm, TimeLogForm, NewEmployeeForm
|
from django.contrib import messages
|
||||||
from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import json
|
import json
|
||||||
|
|
||||||
def is_admin(user):
|
from .forms import (
|
||||||
return user.is_active and user.is_superuser
|
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):
|
def index(request):
|
||||||
contracts = Contract.objects.all()
|
contracts = Contract.objects.all()
|
||||||
for c in contracts:
|
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).aggregate(Sum('hour'))['hour__sum']
|
||||||
c.total_logged = total if total else 0.0
|
c.total_logged = total if total else 0.0
|
||||||
|
|
||||||
employees = Employee.objects.all()
|
employees = get_employees()
|
||||||
employee_data = []
|
employee_data = []
|
||||||
for e in employees:
|
for e in employees:
|
||||||
contract_hours = []
|
contract_hours = []
|
||||||
@@ -28,10 +46,20 @@ def index(request):
|
|||||||
|
|
||||||
return render(request, "financial/index.html", {
|
return render(request, "financial/index.html", {
|
||||||
'contracts': contracts,
|
'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):
|
def new_employee(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = NewEmployeeForm(request.POST)
|
form = NewEmployeeForm(request.POST)
|
||||||
@@ -42,7 +70,8 @@ def new_employee(request):
|
|||||||
form = NewEmployeeForm()
|
form = NewEmployeeForm()
|
||||||
return render(request, 'financial/new_employee.html', {"form": form})
|
return render(request, 'financial/new_employee.html', {"form": form})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def contracts(request):
|
def contracts(request):
|
||||||
contracts_list = Contract.objects.all()
|
contracts_list = Contract.objects.all()
|
||||||
today = timezone.now().date()
|
today = timezone.now().date()
|
||||||
@@ -102,7 +131,8 @@ def contracts(request):
|
|||||||
'chart_data_json': json.dumps(chart_data_list)
|
'chart_data_json': json.dumps(chart_data_list)
|
||||||
})
|
})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def contract_detail(request, contract_slug):
|
def contract_detail(request, contract_slug):
|
||||||
contract = Contract.objects.filter(slug=contract_slug).first()
|
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)
|
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}"
|
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 = contract.get_evm_data() if contract else {}
|
||||||
evm_chart_json = json.dumps({
|
evm_chart_json = json.dumps({
|
||||||
'time_series': evm.get('time_series', []),
|
'time_series': evm.get('time_series', []),
|
||||||
@@ -149,7 +178,8 @@ def contract_detail(request, contract_slug):
|
|||||||
'evm_chart_json': evm_chart_json,
|
'evm_chart_json': evm_chart_json,
|
||||||
})
|
})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def new_contract(request):
|
def new_contract(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = ContractForm(request.POST)
|
form = ContractForm(request.POST)
|
||||||
@@ -160,27 +190,42 @@ def new_contract(request):
|
|||||||
form = ContractForm()
|
form = ContractForm()
|
||||||
return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True})
|
return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_write_required
|
||||||
def timekeeping(request):
|
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":
|
if request.method == "POST":
|
||||||
form = TimeLogForm(request.POST)
|
form = TimeLogForm(request.POST)
|
||||||
if form.is_valid():
|
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 = form.save(commit=False)
|
||||||
cell.timeCard = time_card
|
cell.timeCard = time_card
|
||||||
cell.save()
|
cell.save()
|
||||||
return redirect('financial_index')
|
return redirect('financial_home')
|
||||||
else:
|
else:
|
||||||
form = TimeLogForm()
|
form = TimeLogForm()
|
||||||
return render(request, 'financial/timekeeping.html', {'form': form})
|
return render(request, 'financial/timekeeping.html', {'form': form})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_access_required
|
||||||
def time_logs(request):
|
def time_logs(request):
|
||||||
logs = TimeCardCell.objects.select_related(
|
logs = TimeCardCell.objects.select_related(
|
||||||
'timeCard__employee__user',
|
'timeCard__employee__user',
|
||||||
'charge_number__contract',
|
'charge_number__contract',
|
||||||
|
).filter(
|
||||||
|
timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE,
|
||||||
).order_by('-date', '-created')
|
).order_by('-date', '-created')
|
||||||
|
|
||||||
employee_ids = []
|
employee_ids = []
|
||||||
@@ -231,12 +276,13 @@ def time_logs(request):
|
|||||||
|
|
||||||
return render(request, 'financial/time_logs.html', {
|
return render(request, 'financial/time_logs.html', {
|
||||||
'logs': logs,
|
'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'),
|
'contracts': Contract.objects.order_by('name'),
|
||||||
'charge_numbers': charge_numbers,
|
'charge_numbers': charge_numbers,
|
||||||
'contract_totals': contract_totals,
|
'contract_totals': contract_totals,
|
||||||
'charge_number_totals': charge_number_totals,
|
'charge_number_totals': charge_number_totals,
|
||||||
'grand_total': grand_total,
|
'grand_total': grand_total,
|
||||||
|
'can_edit_logs': can_write_financials(request.user),
|
||||||
'filters': {
|
'filters': {
|
||||||
'employees': [str(eid) for eid in employee_ids],
|
'employees': [str(eid) for eid in employee_ids],
|
||||||
'month': month or '',
|
'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):
|
def edit_time_log(request, log_id):
|
||||||
log_entry = TimeCardCell.objects.filter(id=log_id).first()
|
log_entry = TimeCardCell.objects.filter(id=log_id).first()
|
||||||
if not log_entry:
|
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})
|
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):
|
def delete_time_log(request, log_id):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
log_entry = TimeCardCell.objects.filter(id=log_id).first()
|
log_entry = TimeCardCell.objects.filter(id=log_id).first()
|
||||||
@@ -269,16 +317,24 @@ def delete_time_log(request, log_id):
|
|||||||
log_entry.delete()
|
log_entry.delete()
|
||||||
return redirect('time_logs')
|
return redirect('time_logs')
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_access_required
|
||||||
def client_reports(request):
|
def client_reports(request):
|
||||||
contracts = Contract.objects.all()
|
contracts = Contract.objects.all()
|
||||||
for c in contracts:
|
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.total_logged = total if total else 0.0
|
||||||
c.remaining_budget = c.budget_hours - c.total_logged
|
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):
|
def update_charge_number(request, charge_number_slug):
|
||||||
charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first()
|
charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first()
|
||||||
if not charge_number:
|
if not charge_number:
|
||||||
@@ -297,7 +353,8 @@ def update_charge_number(request, charge_number_slug):
|
|||||||
'charge_number': charge_number,
|
'charge_number': charge_number,
|
||||||
})
|
})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def new_charge_number(request, contract_slug):
|
def new_charge_number(request, contract_slug):
|
||||||
contract = Contract.objects.filter(slug=contract_slug).first()
|
contract = Contract.objects.filter(slug=contract_slug).first()
|
||||||
if request.method == "POST":
|
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)
|
||||||
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):
|
def timeapproval(request):
|
||||||
return render(request, 'financial/not_created.html', {})
|
return render(request, 'financial/not_created.html', {})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def chargenumber(request):
|
def chargenumber(request):
|
||||||
return render(request, 'financial/not_created.html', {})
|
return render(request, 'financial/not_created.html', {})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_admin_required
|
||||||
def procurement(request):
|
def procurement(request):
|
||||||
return render(request, 'financial/procurement.html', {})
|
return render(request, 'financial/procurement.html', {})
|
||||||
|
|
||||||
@user_passes_test(is_admin)
|
|
||||||
|
@financial_access_required
|
||||||
def profile(request):
|
def profile(request):
|
||||||
form = EmployeeForm()
|
profile_obj, _ = UserProfile.objects.get_or_create(user=request.user)
|
||||||
return render(request, 'financial/profile.html', {'form': form})
|
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,
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from .seo import PUBLIC_PAGE_ENTRIES, get_service_entries
|
||||||
|
|
||||||
|
|
||||||
def tianji_tracking(request):
|
def tianji_tracking(request):
|
||||||
@@ -16,3 +21,60 @@ def tianji_tracking(request):
|
|||||||
),
|
),
|
||||||
'page_name': getattr(getattr(request, 'resolver_match', None), 'url_name', ''),
|
'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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ class FormWithCaptcha(forms.Form):
|
|||||||
captcha = ReCaptchaField(
|
captcha = ReCaptchaField(
|
||||||
widget=ReCaptchaV3(
|
widget=ReCaptchaV3(
|
||||||
attrs={
|
attrs={
|
||||||
'required_score':0.85,
|
'required_score': 0.85,
|
||||||
}
|
'form': 'contact-form',
|
||||||
|
}
|
||||||
),
|
),
|
||||||
public_key=settings.RECAPTCHA_PUBLIC_KEY,
|
public_key=settings.RECAPTCHA_PUBLIC_KEY,
|
||||||
private_key=settings.RECAPTCHA_PRIVATE_KEY,
|
private_key=settings.RECAPTCHA_PRIVATE_KEY,
|
||||||
|
|||||||
+112
-14
@@ -5,21 +5,119 @@ from django.template.loader import render_to_string
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
# Public marketing pages included in sitemap and llms.txt.
|
# Public marketing pages included in sitemap and llms.txt.
|
||||||
|
# Tuple: (url_name, title, changefreq, priority, summary)
|
||||||
PUBLIC_PAGE_ENTRIES = (
|
PUBLIC_PAGE_ENTRIES = (
|
||||||
("public_index", "Home", "weekly", "1.0"),
|
(
|
||||||
("forward_deployed", "Forward-Deployed AI", "monthly", "0.9"),
|
"public_index",
|
||||||
("bot", "AI Agents", "monthly", "0.9"),
|
"Home",
|
||||||
("ml_model", "ML Models", "monthly", "0.8"),
|
"weekly",
|
||||||
("chat", "Secure AI Chat", "monthly", "0.8"),
|
"1.0",
|
||||||
("ai_sensor", "AI Sensor Algorithms", "monthly", "0.7"),
|
"Company homepage with an overview of AI ML Operations services.",
|
||||||
("ai_education", "AI Education", "monthly", "0.7"),
|
),
|
||||||
("computers", "Computer Builds", "monthly", "0.7"),
|
(
|
||||||
("file_hosting", "File Hosting", "monthly", "0.7"),
|
"forward_deployed",
|
||||||
("web_design", "Web Design and Hosting", "monthly", "0.8"),
|
"Forward-Deployed AI",
|
||||||
("contact", "Contact", "monthly", "0.9"),
|
"monthly",
|
||||||
("terms_of_service", "Terms of Service and Privacy", "yearly", "0.3"),
|
"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):
|
def _absolute_url(request, url_name):
|
||||||
return request.build_absolute_uri(reverse(url_name))
|
return request.build_absolute_uri(reverse(url_name))
|
||||||
@@ -41,7 +139,7 @@ def sitemap_xml(request):
|
|||||||
"changefreq": changefreq,
|
"changefreq": changefreq,
|
||||||
"priority": priority,
|
"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})
|
content = render_to_string("public/sitemap.xml", {"pages": pages})
|
||||||
return HttpResponse(content, content_type="application/xml; charset=utf-8")
|
return HttpResponse(content, content_type="application/xml; charset=utf-8")
|
||||||
@@ -53,7 +151,7 @@ def llms_txt(request):
|
|||||||
"title": title,
|
"title": title,
|
||||||
"url": _absolute_url(request, url_name),
|
"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(
|
content = render_to_string(
|
||||||
"public/llms.txt",
|
"public/llms.txt",
|
||||||
|
|||||||
@@ -780,6 +780,14 @@ input:focus, select:focus, textarea:focus {
|
|||||||
margin: 0 0 1rem 1.5rem;
|
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 */
|
||||||
.cookie-consent-banner {
|
.cookie-consent-banner {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -44,6 +44,16 @@
|
|||||||
{% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %}
|
{% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %}
|
||||||
hidden></div>
|
hidden></div>
|
||||||
{% endif %}
|
{% 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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -79,9 +89,11 @@
|
|||||||
<li><a href="{% url 'planning:board_view' %}"
|
<li><a href="{% url 'planning:board_view' %}"
|
||||||
class="{% if 'planning' in request.path %}active{% endif %}"
|
class="{% if 'planning' in request.path %}active{% endif %}"
|
||||||
data-tianji-event="nav_planning">Planning</a></li>
|
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 %}"
|
class="{% if 'financial' in request.path %}active{% endif %}"
|
||||||
data-tianji-event="nav_financials">Financials</a></li>
|
data-tianji-event="nav_financials">Financials</a></li>
|
||||||
|
{% endif %}
|
||||||
<li class="dropdown" id="user-profile-dropdown">
|
<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 }}"
|
<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">
|
aria-expanded="false" aria-haspopup="true" aria-controls="profile-menu">
|
||||||
@@ -93,6 +105,12 @@
|
|||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-content profile-dropdown-content" id="profile-menu" role="menu">
|
<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>
|
<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><a href="{% url 'change_password' %}">Change Password</a></li>
|
||||||
<li>
|
<li>
|
||||||
<form action="{% url 'logout' %}" method="post" style="margin: 0;">
|
<form action="{% url 'logout' %}" method="post" style="margin: 0;">
|
||||||
@@ -151,6 +169,10 @@
|
|||||||
<script src="{% static 'public/js/tianji-consent.js' %}"></script>
|
<script src="{% static 'public/js/tianji-consent.js' %}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if webmcp_enabled %}
|
||||||
|
<script src="{% static 'public/js/webmcp-tools.js' %}"></script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
const mobileBtn = document.querySelector('.mobile-menu-btn');
|
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">
|
<div class="card">
|
||||||
<h2 class="card-title contact-form-heading" id="contact-form-heading" style="margin-bottom: 2rem;">Send Us a Message</h2>
|
<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 %}
|
{% csrf_token %}
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="contact-name">Your Name</label>
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="contact-email">Your Email</label>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="contact-subject">Subject</label>
|
<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>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label" for="contact-message">Message</label>
|
<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>
|
<textarea name="message" class="form-control" id="contact-message" rows="5"
|
||||||
</div>
|
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 class="form-group">
|
|
||||||
{% if capchaForm %}
|
|
||||||
{{ capchaForm }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not capchaForm %}
|
||||||
<button class="btn" type="submit"
|
<button class="btn" type="submit"
|
||||||
data-tianji-event="contact_form_submit">
|
data-tianji-event="contact_form_submit">
|
||||||
Send Message
|
Send Message
|
||||||
</button>
|
</button>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,38 @@
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.test import Client, TestCase, override_settings
|
from django.test import Client, TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from .models import Contact
|
from .models import Contact, EmailMessage
|
||||||
|
from .seo import SERVICE_URL_NAMES, get_service_entries
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewEmailAuthTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.user = User.objects.create_user(username="previewer", password="pass")
|
||||||
|
self.email = EmailMessage.objects.create(
|
||||||
|
subject="Preview subject",
|
||||||
|
body="Preview body content",
|
||||||
|
recipient="recipient@example.com",
|
||||||
|
)
|
||||||
|
self.url = reverse("preview_email", kwargs={"pk": self.email.pk})
|
||||||
|
|
||||||
|
def test_unauthenticated_user_is_redirected_to_login(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/accounts/login/", response.url)
|
||||||
|
|
||||||
|
def test_authenticated_user_can_preview_email(self):
|
||||||
|
self.client.login(username="previewer", password="pass")
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Preview subject")
|
||||||
|
self.assertContains(response, "Preview body content")
|
||||||
|
|
||||||
|
|
||||||
@override_settings(
|
@override_settings(
|
||||||
@@ -170,3 +199,74 @@ class AgenticBrowsingSeoTests(TestCase):
|
|||||||
self.assertContains(response, 'id="services-menu-button"')
|
self.assertContains(response, 'id="services-menu-button"')
|
||||||
self.assertContains(response, 'aria-haspopup="true"')
|
self.assertContains(response, 'aria-haspopup="true"')
|
||||||
self.assertContains(response, 'aria-label="Main navigation"')
|
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)
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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"
|
||||||
|
# No required env_file — CI has no .env. Defaults below; for local secrets:
|
||||||
|
# docker compose --env-file .env up
|
||||||
|
environment:
|
||||||
|
DJANGO_ENV: ${DJANGO_ENV:-dev}
|
||||||
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
|
||||||
|
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
|
||||||
|
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
|
||||||
|
DATABASE_URL: ${DATABASE_URL:-postgres://company_site:company_site@db:5432/company_site}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -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
|
||||||
@@ -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
@@ -2,10 +2,9 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_ROOT="/home/westfarn/Documents/django_live_sites/Company_Site"
|
REPO_ROOT="/home/westfarn/Documents/django_live_sites/Company_Site"
|
||||||
APP_DIR="$REPO_ROOT/company_site"
|
ENV_FILE="$REPO_ROOT/.env"
|
||||||
VENV="$REPO_ROOT/venv"
|
COMPOSE_FILE="$REPO_ROOT/docker-compose.prod.yml"
|
||||||
SETTINGS="$APP_DIR/company_site/settings.py"
|
VALIDATE_SCRIPT="$REPO_ROOT/scripts/validate-env.sh"
|
||||||
LOCAL_SETTINGS="$APP_DIR/company_site/local_settings.py"
|
|
||||||
|
|
||||||
if [[ $# -lt 1 || -z "${1:-}" ]]; then
|
if [[ $# -lt 1 || -z "${1:-}" ]]; then
|
||||||
echo "Usage: $0 <checkout-directory>" >&2
|
echo "Usage: $0 <checkout-directory>" >&2
|
||||||
@@ -13,24 +12,67 @@ if [[ $# -lt 1 || -z "${1:-}" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
CHECKOUT="$1"
|
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
|
cp "$LOCAL_SETTINGS" /tmp/company_site_local_settings.py.bak 2>/dev/null || true
|
||||||
|
|
||||||
rsync -a --delete \
|
rsync -a --delete \
|
||||||
--exclude venv/ \
|
--exclude .venv/ \
|
||||||
--exclude .git/ \
|
--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' \
|
--exclude 'company_site/company_site/local_settings.py' \
|
||||||
"$CHECKOUT/" "$REPO_ROOT/"
|
"$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
|
cp /tmp/company_site_local_settings.py.bak "$LOCAL_SETTINGS" 2>/dev/null || true
|
||||||
|
|
||||||
source "$VENV/bin/activate"
|
uv sync --frozen --directory "$REPO_ROOT"
|
||||||
pip install -r "$REPO_ROOT/requirements.txt"
|
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
python manage.py migrate --noinput
|
uv run python manage.py migrate --noinput
|
||||||
python manage.py collectstatic --noinput
|
uv run python manage.py collectstatic --noinput
|
||||||
|
|
||||||
sudo systemctl restart company
|
sudo systemctl restart company
|
||||||
|
|||||||
Executable
+48
@@ -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}"
|
||||||
Executable
+59
@@ -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)."
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user