feat: Add uv Docker, Postgres, and company linking

Introduces uv-based Docker workflow with non-root runtime, cached installs, and uv-run for web and Celery. Updates docker-compose to Postgres + Redis, loads .env, and removes source bind mount for reproducible builds.

Switches settings to use Postgres when env is present with SQLite fallback; broadens allowed hosts for containerized development. Adds psycopg2-binary and updates sample env for Redis in Docker.

Adds company scoping to external data models and links sessions during ingestion; provides management commands to seed a Jumbo company/users and sync external chat data into the dashboard.

Includes .dockerignore, TypeScript config and typings, and minor template/docs tweaks.

Requires database migration.
This commit is contained in:
2025-11-05 20:22:07 +01:00
parent 81d1469e18
commit 2236eeb9a5
21 changed files with 563 additions and 51 deletions

53
.dockerignore Normal file
View File

@@ -0,0 +1,53 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
env/
ENV/
# Django
*.log
db.sqlite3
db.sqlite3-journal
/static/
/media/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Testing
.pytest_cache/
.coverage
htmlcov/
# Git
.git/
.gitignore
# Docker
Dockerfile
docker-compose.yml
.dockerignore
# Documentation
*.md
docs/
# CI/CD
.github/
# Playwright
.playwright-mcp/
# Other
*.bak
*.tmp
node_modules/

View File

@@ -1,8 +1,20 @@
# .env.sample - rename to .env and update with actual credentials # .env.sample - rename to .env and update with actual credentials
# Django settings # Django settings
# Generate secret with e.g. `openssl rand -hex 32`
DJANGO_SECRET_KEY=your-secure-secret-key DJANGO_SECRET_KEY=your-secure-secret-key
DJANGO_DEBUG=True DJANGO_DEBUG=True
# Database configuration (optional - local development uses SQLite by default)
# Uncomment these to use PostgreSQL locally:
# DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dashboard_db
# POSTGRES_DB=dashboard_db
# POSTGRES_USER=postgres
# POSTGRES_PASSWORD=postgres
# POSTGRES_HOST=localhost
# POSTGRES_PORT=5432
#
# Note: Docker Compose automatically uses PostgreSQL via docker-compose.yml environment variables
# External API credentials # External API credentials
EXTERNAL_API_USERNAME=your-api-username EXTERNAL_API_USERNAME=your-api-username
EXTERNAL_API_PASSWORD=your-api-password EXTERNAL_API_PASSWORD=your-api-password
@@ -10,7 +22,7 @@ EXTERNAL_API_PASSWORD=your-api-password
# Redis settings for Celery # Redis settings for Celery
REDIS_URL=redis://localhost:6379/0 REDIS_URL=redis://localhost:6379/0
CELERY_BROKER_URL=redis://localhost:6379/0 CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/0
# Celery Task Schedule (in seconds) # Celery Task Schedule (in seconds)
CHAT_DATA_FETCH_INTERVAL=3600 CHAT_DATA_FETCH_INTERVAL=3600

3
.gitignore vendored
View File

@@ -421,3 +421,6 @@ package-lock.json
# Local database files # Local database files
*.rdb *.rdb
*.sqlite *.sqlite
# playwright
.playwright-mcp/

View File

@@ -1,43 +1,58 @@
# Dockerfile # Dockerfile
FROM python:3.13-slim # Use a Python image with uv pre-installed
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
# Setup a non-root user
RUN groupadd --system --gid 999 nonroot \
&& useradd --system --gid 999 --uid 999 --create-home nonroot
# Set environment variables # Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED=1
ENV PYTHONUNBUFFERED 1
ENV DJANGO_SETTINGS_MODULE=dashboard_project.settings ENV DJANGO_SETTINGS_MODULE=dashboard_project.settings
# Set work directory # Change the working directory to the `app` directory
WORKDIR /app WORKDIR /app
# Install UV for Python package management # Enable bytecode compilation
ENV UV_COMPILE_BYTECODE=1
RUN pip install uv # Copy from the cache instead of linking since it's a mounted volume
ENV UV_LINK_MODE=copy
# Copy project files # Install dependencies (separate layer for caching)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
COPY pyproject.toml . # Copy the project into the image
COPY uv.lock . COPY . /app
COPY . .
# Install dependencies # Sync the project (install the project itself)
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
RUN uv pip install -e . # Place executables in the environment at the front of the path
ENV PATH="/app/.venv/bin:$PATH"
# Change to the Django project directory # Change to the Django project directory
WORKDIR /app/dashboard_project WORKDIR /app/dashboard_project
# Collect static files # Collect static files (runs as root)
RUN uv run manage.py collectstatic --noinput
RUN python manage.py collectstatic --noinput # Fix ownership of dashboard_project directory for nonroot user
# This ensures db.sqlite3 and any files created during runtime are writable
RUN chown -R nonroot:nonroot /app/dashboard_project && \
chmod 775 /app/dashboard_project
# Change back to the app directory # Change back to the app directory
WORKDIR /app WORKDIR /app
# Run gunicorn # Use the non-root user to run our application
USER nonroot
CMD ["gunicorn", "dashboard_project.wsgi:application", "--bind", "0.0.0.0:8000"] # Run gunicorn via uv run to ensure it's in the environment
CMD ["uv", "run", "gunicorn", "dashboard_project.wsgi:application", "--bind", "0.0.0.0:8000", "--chdir", "dashboard_project"]

View File

@@ -4,6 +4,7 @@
"": { "": {
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.56.1", "@playwright/test": "^1.56.1",
"@types/bun": "latest",
"markdownlint-cli2": "^0.18.1", "markdownlint-cli2": "^0.18.1",
"oxlint": "^1.25.0", "oxlint": "^1.25.0",
"oxlint-tsgolint": "^0.5.0", "oxlint-tsgolint": "^0.5.0",
@@ -11,6 +12,9 @@
"prettier-plugin-jinja-template": "^2.1.0", "prettier-plugin-jinja-template": "^2.1.0",
"prettier-plugin-packagejson": "^2.5.19", "prettier-plugin-packagejson": "^2.5.19",
}, },
"peerDependencies": {
"typescript": "^5",
},
}, },
}, },
"packages": { "packages": {
@@ -54,18 +58,26 @@
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="], "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@2.3.0", "", {}, "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg=="],
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
@@ -74,6 +86,8 @@
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
@@ -238,8 +252,12 @@
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],

View File

@@ -27,7 +27,21 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", get_random_secret_key())
DEBUG = os.environ.get("DJANGO_DEBUG", "True") == "True" DEBUG = os.environ.get("DJANGO_DEBUG", "True") == "True"
ALLOWED_HOSTS = [] # Allow localhost, Docker IPs, and entire private network ranges
ALLOWED_HOSTS = [
"localhost",
"127.0.0.1",
"0.0.0.0", # nosec B104
".localhost",
# Allow all 192.168.x.x addresses (private network)
"192.168.*.*",
# Allow all 10.x.x.x addresses (Docker default)
"10.*.*.*",
# Allow all 172.16-31.x.x addresses (Docker)
"172.*.*.*",
# Wildcard for any other IPs (development only)
"*",
]
# Application definition # Application definition
@@ -85,13 +99,28 @@ TEMPLATES = [
WSGI_APPLICATION = "dashboard_project.wsgi.application" WSGI_APPLICATION = "dashboard_project.wsgi.application"
# Database # Database
# Use PostgreSQL when DATABASE_URL is set (Docker), otherwise SQLite (local dev)
DATABASES = { if os.environ.get("DATABASE_URL"):
"default": { # PostgreSQL configuration for Docker
"ENGINE": "django.db.backends.sqlite3", DATABASES = {
"NAME": BASE_DIR / "db.sqlite3", "default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB", "dashboard_db"),
"USER": os.environ.get("POSTGRES_USER", "postgres"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "postgres"),
"HOST": os.environ.get("POSTGRES_HOST", "db"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
}
}
else:
# SQLite configuration for local development
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
} }
}
# Password validation # Password validation

View File

@@ -0,0 +1,107 @@
"""
Management command to set up Jumbo company, users, and link existing data.
"""
from accounts.models import Company, CustomUser
from data_integration.models import ChatSession, ExternalDataSource
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Set up Jumbo company, create users, and link existing external data"
def handle(self, *_args, **_options):
self.stdout.write("Setting up Jumbo company and data...")
# 1. Create Jumbo company
jumbo_company, created = Company.objects.get_or_create(
name="Jumbo", defaults={"description": "Jumbo Supermarkets - External API Data"}
)
if created:
self.stdout.write(self.style.SUCCESS("✓ Created Jumbo company"))
else:
self.stdout.write(" Jumbo company already exists")
# 2. Create admin user for Jumbo
admin_created = False
if not CustomUser.objects.filter(username="jumbo_admin").exists():
CustomUser.objects.create_user( # nosec B106
username="jumbo_admin",
email="admin@jumbo.nl",
password="jumbo123",
company=jumbo_company,
is_company_admin=True,
)
self.stdout.write(self.style.SUCCESS("✓ Created Jumbo admin: jumbo_admin / jumbo123"))
admin_created = True
else:
self.stdout.write(" Jumbo admin already exists")
# 3. Create regular users for Jumbo
jumbo_users = [
{
"username": "jumbo_analyst",
"email": "analyst@jumbo.nl",
"password": "jumbo123",
"is_company_admin": False,
},
{
"username": "jumbo_manager",
"email": "manager@jumbo.nl",
"password": "jumbo123",
"is_company_admin": False,
},
]
users_created = 0
for user_data in jumbo_users:
if not CustomUser.objects.filter(username=user_data["username"]).exists():
CustomUser.objects.create_user(
username=user_data["username"],
email=user_data["email"],
password=user_data["password"],
company=jumbo_company,
is_company_admin=user_data["is_company_admin"],
)
users_created += 1
if users_created:
self.stdout.write(self.style.SUCCESS(f"✓ Created {users_created} Jumbo users"))
else:
self.stdout.write(" Jumbo users already exist")
# 4. Link External Data Source to Jumbo company
try:
jumbo_ext_source = ExternalDataSource.objects.get(name="Jumbo API")
if not jumbo_ext_source.company:
jumbo_ext_source.company = jumbo_company
jumbo_ext_source.save()
self.stdout.write(self.style.SUCCESS("✓ Linked Jumbo API data source to company"))
else:
self.stdout.write(" Jumbo API data source already linked")
except ExternalDataSource.DoesNotExist:
self.stdout.write(
self.style.WARNING("⚠ Jumbo API external data source not found. Create it in admin first.")
)
# 5. Link existing chat sessions to Jumbo company
unlinked_sessions = ChatSession.objects.filter(company__isnull=True)
if unlinked_sessions.exists():
count = unlinked_sessions.update(company=jumbo_company)
self.stdout.write(self.style.SUCCESS(f"✓ Linked {count} existing chat sessions to Jumbo company"))
else:
self.stdout.write(" All chat sessions already linked to companies")
# 6. Summary
total_sessions = ChatSession.objects.filter(company=jumbo_company).count()
total_users = CustomUser.objects.filter(company=jumbo_company).count()
self.stdout.write(
self.style.SUCCESS(
f"\n✓ Setup complete!"
f"\n Company: {jumbo_company.name}"
f"\n Users: {total_users} (including {1 if admin_created or CustomUser.objects.filter(username='jumbo_admin').exists() else 0} admin)"
f"\n Chat sessions: {total_sessions}"
)
)
self.stdout.write("\nLogin as jumbo_admin/jumbo123 to view the dashboard with Jumbo data.")

View File

@@ -0,0 +1,106 @@
"""
Management command to sync Jumbo API data to dashboard app with proper company linking.
"""
from accounts.models import Company, CustomUser
from dashboard.models import ChatSession, DataSource
from data_integration.models import ChatSession as ExtChatSession
from data_integration.models import ExternalDataSource
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Sync Jumbo API data to dashboard app with company linking"
def handle(self, *_args, **_options):
self.stdout.write("Starting Jumbo data sync to dashboard...")
# 1. Get or create Jumbo company
jumbo_company, created = Company.objects.get_or_create(
name="Jumbo", defaults={"description": "Jumbo Supermarkets - External API Data"}
)
if created:
self.stdout.write(self.style.SUCCESS("✓ Created Jumbo company"))
else:
self.stdout.write(" Jumbo company already exists")
# 2. Get Jumbo external data source
try:
jumbo_ext_source = ExternalDataSource.objects.get(name="Jumbo API")
except ExternalDataSource.DoesNotExist:
self.stdout.write(
self.style.ERROR("✗ Jumbo API external data source not found. Please create it in admin first.")
)
return
# 3. Get or create DataSource linked to Jumbo company
jumbo_datasource, created = DataSource.objects.get_or_create(
name="Jumbo API Data",
company=jumbo_company,
defaults={
"description": "Chat sessions from Jumbo external API",
"external_source": jumbo_ext_source,
},
)
if created:
self.stdout.write(self.style.SUCCESS("✓ Created Jumbo DataSource"))
else:
self.stdout.write(" Jumbo DataSource already exists")
# 4. Sync chat sessions from data_integration to dashboard
ext_sessions = ExtChatSession.objects.all()
synced_count = 0
skipped_count = 0
for ext_session in ext_sessions:
# Check if already synced
if ChatSession.objects.filter(data_source=jumbo_datasource, session_id=ext_session.session_id).exists():
skipped_count += 1
continue
# Create dashboard ChatSession
ChatSession.objects.create(
data_source=jumbo_datasource,
session_id=ext_session.session_id,
start_time=ext_session.start_time,
end_time=ext_session.end_time,
ip_address=ext_session.ip_address,
country=ext_session.country or "",
language=ext_session.language or "",
messages_sent=ext_session.messages_sent or 0,
sentiment=ext_session.sentiment or "",
escalated=ext_session.escalated or False,
forwarded_hr=ext_session.forwarded_hr or False,
full_transcript=ext_session.full_transcript_url or "",
avg_response_time=ext_session.avg_response_time,
tokens=ext_session.tokens or 0,
tokens_eur=ext_session.tokens_eur,
category=ext_session.category or "",
initial_msg=ext_session.initial_msg or "",
user_rating=str(ext_session.user_rating) if ext_session.user_rating else "",
)
synced_count += 1
self.stdout.write(
self.style.SUCCESS(f"✓ Synced {synced_count} chat sessions (skipped {skipped_count} existing)")
)
# 5. Create admin user for Jumbo company if needed
if not CustomUser.objects.filter(company=jumbo_company, is_company_admin=True).exists():
CustomUser.objects.create_user( # nosec B106
username="jumbo_admin",
email="admin@jumbo.nl",
password="jumbo123",
company=jumbo_company,
is_company_admin=True,
)
self.stdout.write(self.style.SUCCESS("✓ Created Jumbo admin user: jumbo_admin / jumbo123"))
else:
self.stdout.write(" Jumbo admin user already exists")
self.stdout.write(
self.style.SUCCESS(
f"\n✓ Sync complete! Jumbo company now has {ChatSession.objects.filter(data_source__company=jumbo_company).count()} chat sessions"
)
)
self.stdout.write("\nLogin as jumbo_admin to view the dashboard with Jumbo data.")

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2025-11-05 18:20
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
("data_integration", "0002_externaldatasource_error_count_and_more"),
]
operations = [
migrations.AddField(
model_name="chatsession",
name="company",
field=models.ForeignKey(
blank=True,
help_text="Company this session belongs to",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="external_chat_sessions",
to="accounts.company",
),
),
migrations.AddField(
model_name="externaldatasource",
name="company",
field=models.ForeignKey(
blank=True,
help_text="Company this data source belongs to",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="external_data_sources",
to="accounts.company",
),
),
]

View File

@@ -1,10 +1,19 @@
import os import os
from accounts.models import Company
from django.db import models from django.db import models
class ChatSession(models.Model): class ChatSession(models.Model):
session_id = models.CharField(max_length=255, unique=True) session_id = models.CharField(max_length=255, unique=True)
company = models.ForeignKey(
Company,
on_delete=models.CASCADE,
related_name="external_chat_sessions",
null=True,
blank=True,
help_text="Company this session belongs to",
)
start_time = models.DateTimeField() start_time = models.DateTimeField()
end_time = models.DateTimeField() end_time = models.DateTimeField()
ip_address = models.GenericIPAddressField(null=True, blank=True) ip_address = models.GenericIPAddressField(null=True, blank=True)
@@ -39,7 +48,15 @@ class ChatMessage(models.Model):
class ExternalDataSource(models.Model): class ExternalDataSource(models.Model):
name = models.CharField(max_length=255, default="External API") name = models.CharField(max_length=255, default="External API")
api_url = models.URLField(default="<https://proto.notso.ai/jumbo/chats>") company = models.ForeignKey(
Company,
on_delete=models.CASCADE,
related_name="external_data_sources",
null=True,
blank=True,
help_text="Company this data source belongs to",
)
api_url = models.URLField(default="https://proto.notso.ai/jumbo/chats")
auth_username = models.CharField(max_length=255, blank=True, null=True) auth_username = models.CharField(max_length=255, blank=True, null=True)
auth_password = models.CharField( auth_password = models.CharField(
max_length=255, blank=True, null=True max_length=255, blank=True, null=True

View File

@@ -144,6 +144,7 @@ def fetch_and_store_chat_data(source_id=None):
session, created = ChatSession.objects.update_or_create( session, created = ChatSession.objects.update_or_create(
session_id=data["session_id"], session_id=data["session_id"],
defaults={ defaults={
"company": source.company, # Link to the company from the data source
"start_time": start_time, "start_time": start_time,
"end_time": end_time, "end_time": end_time,
"ip_address": data.get("ip_address"), "ip_address": data.get("ip_address"),

View File

@@ -1,3 +1,4 @@
{% load crispy_forms_filters %}
<!-- templates/accounts/login.html --> <!-- templates/accounts/login.html -->
{% extends 'base.html' %} {% load crispy_forms_tags %} {% extends 'base.html' %} {% load crispy_forms_tags %}
{% block title %} {% block title %}

View File

@@ -1,22 +1,23 @@
# docker-compose.yml # docker-compose.yml
version: "3.8"
services: services:
web: web:
build: . build: .
command: gunicorn dashboard_project.wsgi:application --bind 0.0.0.0:8000 command: uv run gunicorn dashboard_project.wsgi:application --bind 0.0.0.0:8000 --chdir dashboard_project
volumes: volumes:
- .:/app
- static_volume:/app/staticfiles - static_volume:/app/staticfiles
- media_volume:/app/media - media_volume:/app/media
ports: ports:
- 8000:8000 - 8000:8000
env_file:
- .env
environment: environment:
- DEBUG=0 - DATABASE_URL=postgresql://postgres:postgres@db:5432/dashboard_db
- SECRET_KEY=your_secret_key_here - POSTGRES_DB=dashboard_db
- ALLOWED_HOSTS=localhost,127.0.0.1 - POSTGRES_USER=postgres
- DJANGO_SETTINGS_MODULE=dashboard_project.settings - POSTGRES_PASSWORD=postgres
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on: depends_on:
@@ -24,7 +25,7 @@ services:
- redis - redis
db: db:
image: postgres:13 image: postgres:alpine
volumes: volumes:
- postgres_data:/var/lib/postgresql/data/ - postgres_data:/var/lib/postgresql/data/
environment: environment:
@@ -35,7 +36,7 @@ services:
- 5432:5432 - 5432:5432
redis: redis:
image: redis:7-alpine image: redis:alpine
ports: ports:
- 6379:6379 - 6379:6379
volumes: volumes:
@@ -48,12 +49,16 @@ services:
celery: celery:
build: . build: .
command: celery -A dashboard_project worker --loglevel=info command: uv run celery -A dashboard_project worker --loglevel=info --workdir dashboard_project
volumes: env_file:
- .:/app - .env
environment: environment:
- DEBUG=0 - DATABASE_URL=postgresql://postgres:postgres@db:5432/dashboard_db
- DJANGO_SETTINGS_MODULE=dashboard_project.settings - POSTGRES_DB=dashboard_db
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on: depends_on:
@@ -62,12 +67,16 @@ services:
celery-beat: celery-beat:
build: . build: .
command: celery -A dashboard_project beat --scheduler django_celery_beat.schedulers:DatabaseScheduler command: uv run celery -A dashboard_project beat --scheduler django_celery_beat.schedulers:DatabaseScheduler --workdir dashboard_project
volumes: env_file:
- .:/app - .env
environment: environment:
- DEBUG=0 - DATABASE_URL=postgresql://postgres:postgres@db:5432/dashboard_db
- DJANGO_SETTINGS_MODULE=dashboard_project.settings - POSTGRES_DB=dashboard_db
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on: depends_on:

View File

@@ -1,6 +1,7 @@
# Redis and Celery Configuration # Redis and Celery Configuration
This document explains how to set up and use Redis and Celery for background task processing in the LiveGraphs application. This document explains how to set up and use Redis and Celery for background task processing in the LiveGraphs
application.
## Overview ## Overview
@@ -72,7 +73,8 @@ Download and install from [microsoftarchive/redis](https://github.com/microsofta
### SQLite Fallback ### SQLite Fallback
If Redis is not available, the application will automatically fall back to using SQLite for Celery tasks. This works well for development but is not recommended for production. If Redis is not available, the application will automatically fall back to using SQLite for Celery tasks. This works
well for development but is not recommended for production.
## Configuration ## Configuration

View File

@@ -54,7 +54,8 @@ If this fails, check the following:
## Fixing CSV Data Processing Issues ## Fixing CSV Data Processing Issues
If you see the error `zip() argument 2 is shorter than argument 1`, it means the data format doesn't match the expected headers. We've implemented a fix that: If you see the error `zip() argument 2 is shorter than argument 1`, it means the data format doesn't match the expected
headers. We've implemented a fix that:
1. Pads shorter rows with empty strings 1. Pads shorter rows with empty strings
2. Uses more flexible date format parsing 2. Uses more flexible date format parsing

View File

@@ -1,4 +1,6 @@
{ {
"name": "livegraphs-django",
"private": true,
"scripts": { "scripts": {
"format": "prettier --write .; bun format:py", "format": "prettier --write .; bun format:py",
"format:check": "prettier --check .; bun format:py -- --check", "format:check": "prettier --check .; bun format:py -- --check",
@@ -16,11 +18,15 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.56.1", "@playwright/test": "^1.56.1",
"@types/bun": "latest",
"markdownlint-cli2": "^0.18.1", "markdownlint-cli2": "^0.18.1",
"oxlint": "^1.25.0", "oxlint": "^1.25.0",
"oxlint-tsgolint": "^0.5.0", "oxlint-tsgolint": "^0.5.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-jinja-template": "^2.1.0", "prettier-plugin-jinja-template": "^2.1.0",
"prettier-plugin-packagejson": "^2.5.19" "prettier-plugin-packagejson": "^2.5.19"
},
"peerDependencies": {
"typescript": "^5"
} }
} }

View File

@@ -26,6 +26,7 @@ dependencies = [
"numpy>=2.3.4", "numpy>=2.3.4",
"pandas>=2.3.3", "pandas>=2.3.3",
"plotly>=6.4.0", "plotly>=6.4.0",
"psycopg2-binary>=2.9.11",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"redis>=7.0.1", "redis>=7.0.1",
"requests>=2.32.5", "requests>=2.32.5",

View File

@@ -413,6 +413,31 @@ prompt-toolkit==3.0.52 \
--hash=sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855 \ --hash=sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855 \
--hash=sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955 --hash=sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955
# via click-repl # via click-repl
psycopg2-binary==2.9.11 \
--hash=sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1 \
--hash=sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b \
--hash=sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee \
--hash=sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316 \
--hash=sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c \
--hash=sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3 \
--hash=sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f \
--hash=sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0 \
--hash=sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1 \
--hash=sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5 \
--hash=sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f \
--hash=sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c \
--hash=sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c \
--hash=sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c \
--hash=sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4 \
--hash=sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766 \
--hash=sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d \
--hash=sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60 \
--hash=sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8 \
--hash=sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f \
--hash=sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f \
--hash=sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa \
--hash=sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747
# via livegraphsdjango
pygments==2.19.2 \ pygments==2.19.2 \
--hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \
--hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b

7
seed.spec.ts Normal file
View File

@@ -0,0 +1,7 @@
import { test, expect } from "@playwright/test";
test.describe("Test group", () => {
test("seed", async ({ page }) => {
// generate code here.
});
});

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

32
uv.lock generated
View File

@@ -542,6 +542,7 @@ dependencies = [
{ name = "numpy" }, { name = "numpy" },
{ name = "pandas" }, { name = "pandas" },
{ name = "plotly" }, { name = "plotly" },
{ name = "psycopg2-binary" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "redis" }, { name = "redis" },
{ name = "requests" }, { name = "requests" },
@@ -579,6 +580,7 @@ requires-dist = [
{ name = "numpy", specifier = ">=2.3.4" }, { name = "numpy", specifier = ">=2.3.4" },
{ name = "pandas", specifier = ">=2.3.3" }, { name = "pandas", specifier = ">=2.3.3" },
{ name = "plotly", specifier = ">=6.4.0" }, { name = "plotly", specifier = ">=6.4.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
{ name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "redis", specifier = ">=7.0.1" }, { name = "redis", specifier = ">=7.0.1" },
{ name = "requests", specifier = ">=2.32.5" }, { name = "requests", specifier = ">=2.32.5" },
@@ -846,6 +848,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
] ]
[[package]]
name = "psycopg2-binary"
version = "2.9.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" version = "2.19.2"