From 2236eeb9a58fbf35dd82d532b22876bf577ac2ec Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Wed, 5 Nov 2025 20:22:07 +0100 Subject: [PATCH] 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. --- .dockerignore | 53 +++++++++ .env.sample | 14 ++- .gitignore | 3 + Dockerfile | 55 +++++---- bun.lock | 18 +++ .../dashboard_project/settings.py | 41 ++++++- .../management/commands/setup_jumbo.py | 107 ++++++++++++++++++ .../commands/sync_jumbo_to_dashboard.py | 106 +++++++++++++++++ ...sion_company_externaldatasource_company.py | 38 +++++++ dashboard_project/data_integration/models.py | 19 +++- dashboard_project/data_integration/utils.py | 1 + .../templates/accounts/login.html | 1 + docker-compose.yml | 49 ++++---- docs/CELERY_REDIS.md | 6 +- docs/TROUBLESHOOTING.md | 3 +- package.json | 6 + pyproject.toml | 1 + requirements.txt | 25 ++++ seed.spec.ts | 7 ++ tsconfig.json | 29 +++++ uv.lock | 32 ++++++ 21 files changed, 563 insertions(+), 51 deletions(-) create mode 100644 .dockerignore create mode 100644 dashboard_project/data_integration/management/commands/setup_jumbo.py create mode 100644 dashboard_project/data_integration/management/commands/sync_jumbo_to_dashboard.py create mode 100644 dashboard_project/data_integration/migrations/0003_chatsession_company_externaldatasource_company.py create mode 100644 seed.spec.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d3ee4db --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.env.sample b/.env.sample index 260c2c2..fede73c 100644 --- a/.env.sample +++ b/.env.sample @@ -1,8 +1,20 @@ # .env.sample - rename to .env and update with actual credentials # Django settings +# Generate secret with e.g. `openssl rand -hex 32` DJANGO_SECRET_KEY=your-secure-secret-key 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_USERNAME=your-api-username EXTERNAL_API_PASSWORD=your-api-password @@ -10,7 +22,7 @@ EXTERNAL_API_PASSWORD=your-api-password # Redis settings for Celery REDIS_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) CHAT_DATA_FETCH_INTERVAL=3600 diff --git a/.gitignore b/.gitignore index 17c5f4f..c28220d 100644 --- a/.gitignore +++ b/.gitignore @@ -421,3 +421,6 @@ package-lock.json # Local database files *.rdb *.sqlite + +# playwright +.playwright-mcp/ diff --git a/Dockerfile b/Dockerfile index bce041a..843530b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,43 +1,58 @@ # 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 - -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 ENV DJANGO_SETTINGS_MODULE=dashboard_project.settings -# Set work directory - +# Change the working directory to the `app` directory 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 uv.lock . -COPY . . +# Copy the project into the image +COPY . /app -# 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 - 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 - 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"] diff --git a/bun.lock b/bun.lock index 0c2a128..b60b9dc 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "devDependencies": { "@playwright/test": "^1.56.1", + "@types/bun": "latest", "markdownlint-cli2": "^0.18.1", "oxlint": "^1.25.0", "oxlint-tsgolint": "^0.5.0", @@ -11,6 +12,9 @@ "prettier-plugin-jinja-template": "^2.1.0", "prettier-plugin-packagejson": "^2.5.19", }, + "peerDependencies": { + "typescript": "^5", + }, }, }, "packages": { @@ -54,18 +58,26 @@ "@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/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], "@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=="], "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=="], + "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-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=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "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=="], @@ -238,8 +252,12 @@ "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=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], diff --git a/dashboard_project/dashboard_project/settings.py b/dashboard_project/dashboard_project/settings.py index 0956818..5b3fcb9 100644 --- a/dashboard_project/dashboard_project/settings.py +++ b/dashboard_project/dashboard_project/settings.py @@ -27,7 +27,21 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", get_random_secret_key()) 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 @@ -85,13 +99,28 @@ TEMPLATES = [ WSGI_APPLICATION = "dashboard_project.wsgi.application" # Database +# Use PostgreSQL when DATABASE_URL is set (Docker), otherwise SQLite (local dev) -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", +if os.environ.get("DATABASE_URL"): + # PostgreSQL configuration for Docker + DATABASES = { + "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 diff --git a/dashboard_project/data_integration/management/commands/setup_jumbo.py b/dashboard_project/data_integration/management/commands/setup_jumbo.py new file mode 100644 index 0000000..d3d7de7 --- /dev/null +++ b/dashboard_project/data_integration/management/commands/setup_jumbo.py @@ -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.") diff --git a/dashboard_project/data_integration/management/commands/sync_jumbo_to_dashboard.py b/dashboard_project/data_integration/management/commands/sync_jumbo_to_dashboard.py new file mode 100644 index 0000000..049764c --- /dev/null +++ b/dashboard_project/data_integration/management/commands/sync_jumbo_to_dashboard.py @@ -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.") diff --git a/dashboard_project/data_integration/migrations/0003_chatsession_company_externaldatasource_company.py b/dashboard_project/data_integration/migrations/0003_chatsession_company_externaldatasource_company.py new file mode 100644 index 0000000..89f7b5c --- /dev/null +++ b/dashboard_project/data_integration/migrations/0003_chatsession_company_externaldatasource_company.py @@ -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", + ), + ), + ] diff --git a/dashboard_project/data_integration/models.py b/dashboard_project/data_integration/models.py index 06ca964..3b15829 100644 --- a/dashboard_project/data_integration/models.py +++ b/dashboard_project/data_integration/models.py @@ -1,10 +1,19 @@ import os +from accounts.models import Company from django.db import models class ChatSession(models.Model): 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() end_time = models.DateTimeField() ip_address = models.GenericIPAddressField(null=True, blank=True) @@ -39,7 +48,15 @@ class ChatMessage(models.Model): class ExternalDataSource(models.Model): name = models.CharField(max_length=255, default="External API") - api_url = models.URLField(default="") + 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_password = models.CharField( max_length=255, blank=True, null=True diff --git a/dashboard_project/data_integration/utils.py b/dashboard_project/data_integration/utils.py index b98924d..f3f1953 100644 --- a/dashboard_project/data_integration/utils.py +++ b/dashboard_project/data_integration/utils.py @@ -144,6 +144,7 @@ def fetch_and_store_chat_data(source_id=None): session, created = ChatSession.objects.update_or_create( session_id=data["session_id"], defaults={ + "company": source.company, # Link to the company from the data source "start_time": start_time, "end_time": end_time, "ip_address": data.get("ip_address"), diff --git a/dashboard_project/templates/accounts/login.html b/dashboard_project/templates/accounts/login.html index 232a4a7..d3e6f3e 100644 --- a/dashboard_project/templates/accounts/login.html +++ b/dashboard_project/templates/accounts/login.html @@ -1,3 +1,4 @@ +{% load crispy_forms_filters %} {% extends 'base.html' %} {% load crispy_forms_tags %} {% block title %} diff --git a/docker-compose.yml b/docker-compose.yml index feefa81..47119d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,23 @@ # docker-compose.yml -version: "3.8" - services: web: 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: - - .:/app - static_volume:/app/staticfiles - media_volume:/app/media ports: - 8000:8000 + env_file: + - .env environment: - - DEBUG=0 - - SECRET_KEY=your_secret_key_here - - ALLOWED_HOSTS=localhost,127.0.0.1 - - DJANGO_SETTINGS_MODULE=dashboard_project.settings + - DATABASE_URL=postgresql://postgres:postgres@db:5432/dashboard_db + - POSTGRES_DB=dashboard_db + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_HOST=db + - POSTGRES_PORT=5432 - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: @@ -24,7 +25,7 @@ services: - redis db: - image: postgres:13 + image: postgres:alpine volumes: - postgres_data:/var/lib/postgresql/data/ environment: @@ -35,7 +36,7 @@ services: - 5432:5432 redis: - image: redis:7-alpine + image: redis:alpine ports: - 6379:6379 volumes: @@ -48,12 +49,16 @@ services: celery: build: . - command: celery -A dashboard_project worker --loglevel=info - volumes: - - .:/app + command: uv run celery -A dashboard_project worker --loglevel=info --workdir dashboard_project + env_file: + - .env environment: - - DEBUG=0 - - DJANGO_SETTINGS_MODULE=dashboard_project.settings + - DATABASE_URL=postgresql://postgres:postgres@db:5432/dashboard_db + - POSTGRES_DB=dashboard_db + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_HOST=db + - POSTGRES_PORT=5432 - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: @@ -62,12 +67,16 @@ services: celery-beat: build: . - command: celery -A dashboard_project beat --scheduler django_celery_beat.schedulers:DatabaseScheduler - volumes: - - .:/app + command: uv run celery -A dashboard_project beat --scheduler django_celery_beat.schedulers:DatabaseScheduler --workdir dashboard_project + env_file: + - .env environment: - - DEBUG=0 - - DJANGO_SETTINGS_MODULE=dashboard_project.settings + - DATABASE_URL=postgresql://postgres:postgres@db:5432/dashboard_db + - POSTGRES_DB=dashboard_db + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_HOST=db + - POSTGRES_PORT=5432 - CELERY_BROKER_URL=redis://redis:6379/0 - CELERY_RESULT_BACKEND=redis://redis:6379/0 depends_on: diff --git a/docs/CELERY_REDIS.md b/docs/CELERY_REDIS.md index 352ca54..94630ee 100644 --- a/docs/CELERY_REDIS.md +++ b/docs/CELERY_REDIS.md @@ -1,6 +1,7 @@ # 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 @@ -72,7 +73,8 @@ Download and install from [microsoftarchive/redis](https://github.com/microsofta ### 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 diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a365152..198c131 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -54,7 +54,8 @@ If this fails, check the following: ## 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 2. Uses more flexible date format parsing diff --git a/package.json b/package.json index 639fc15..699a2ae 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,6 @@ { + "name": "livegraphs-django", + "private": true, "scripts": { "format": "prettier --write .; bun format:py", "format:check": "prettier --check .; bun format:py -- --check", @@ -16,11 +18,15 @@ }, "devDependencies": { "@playwright/test": "^1.56.1", + "@types/bun": "latest", "markdownlint-cli2": "^0.18.1", "oxlint": "^1.25.0", "oxlint-tsgolint": "^0.5.0", "prettier": "^3.6.2", "prettier-plugin-jinja-template": "^2.1.0", "prettier-plugin-packagejson": "^2.5.19" + }, + "peerDependencies": { + "typescript": "^5" } } diff --git a/pyproject.toml b/pyproject.toml index 067ac35..3e826ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "numpy>=2.3.4", "pandas>=2.3.3", "plotly>=6.4.0", + "psycopg2-binary>=2.9.11", "python-dotenv>=1.2.1", "redis>=7.0.1", "requests>=2.32.5", diff --git a/requirements.txt b/requirements.txt index 0dd33a7..7127c81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -413,6 +413,31 @@ prompt-toolkit==3.0.52 \ --hash=sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855 \ --hash=sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955 # 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 \ --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b diff --git a/seed.spec.ts b/seed.spec.ts new file mode 100644 index 0000000..683fe83 --- /dev/null +++ b/seed.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Test group", () => { + test("seed", async ({ page }) => { + // generate code here. + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -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 + } +} diff --git a/uv.lock b/uv.lock index d7c77eb..c038062 100644 --- a/uv.lock +++ b/uv.lock @@ -542,6 +542,7 @@ dependencies = [ { name = "numpy" }, { name = "pandas" }, { name = "plotly" }, + { name = "psycopg2-binary" }, { name = "python-dotenv" }, { name = "redis" }, { name = "requests" }, @@ -579,6 +580,7 @@ requires-dist = [ { name = "numpy", specifier = ">=2.3.4" }, { name = "pandas", specifier = ">=2.3.3" }, { name = "plotly", specifier = ">=6.4.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "redis", specifier = ">=7.0.1" }, { 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" }, ] +[[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]] name = "pygments" version = "2.19.2"